diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..b9b7b3482 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +deployment/ +*.tf +*.log +.git/ diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc deleted file mode 100644 index 036827822..000000000 --- a/config/credentials.yml.enc +++ /dev/null @@ -1 +0,0 @@ -owsEG/FJlm9Yhw3L2rI1MShG0a+AzpRyn1UhJBnriW5u/ox/pPY0NBM6P/zBy3kKG6JaDXctFHOtmY1CW/cNOYT210S/wSa6R5eSHCTYgTPwVTrilELU/vuA6ivCxWBPTLSFz8rxJ0mJRKErgnYro06BLd3oK4SPizgmSJD59WOG7iCENeELb8f4JWhOolNWKHR/eggiKPWHU0OCFPt/RyI/y8wsmnP67188ChEBqnbgldfe6UaJlvS7JWibRutDPQrdrrRvT7gin7/sM2N7dDV+gwbXvCZkL5tCXlnVii9OpcdBYfD7zzcnwmwsKa4AQ/lPseYfHF4HlQgo33aG8L9+PQKVnPqkmJHpXh1db9zXkPGfwb16MUTQ8JXLcNPl9ZFZwSZ1o696+su9Hn4c3Fh9SWju2bG3S5+U--qYgY+cOz6SaDxjwW--kl+jhp0mvm8Faf4GczjICg== \ No newline at end of file diff --git a/deployment/ecs.zip b/deployment/ecs.zip deleted file mode 100644 index b3e80cc6f..000000000 Binary files a/deployment/ecs.zip and /dev/null differ diff --git a/deployment/ecs/.gitignore b/deployment/ecs/.gitignore index 4260bbcff..22666290f 100644 --- a/deployment/ecs/.gitignore +++ b/deployment/ecs/.gitignore @@ -3,7 +3,6 @@ .sql # Local .terraform directories **/.terraform/* - .terraform* .terraform.lock.hcl # .tfstate files diff --git a/deployment/ecs/backend-prod.hcl b/deployment/ecs/backend-prod.hcl index 87355e40b..a89f3aa6f 100644 --- a/deployment/ecs/backend-prod.hcl +++ b/deployment/ecs/backend-prod.hcl @@ -1,5 +1,5 @@ -bucket = "mpath-terraform-state" -key = "mpath/ecs/vpc/terraform.tfstate" +bucket = "mpath-terraform-remote-state" +key = "mpath/vpc/terraform.tfstate" region = "us-east-1" encrypt = true use_lockfile = true \ No newline at end of file diff --git a/deployment/ecs/envs/bo/.gitignore b/deployment/ecs/envs/bo/.gitignore new file mode 100644 index 000000000..54f13ebe4 --- /dev/null +++ b/deployment/ecs/envs/bo/.gitignore @@ -0,0 +1,2 @@ +app_env.json +app_env*.json diff --git a/deployment/ecs/envs/bo/app_env_local_file.tf b/deployment/ecs/envs/bo/app_env_local_file.tf new file mode 100644 index 000000000..9a1fbab85 --- /dev/null +++ b/deployment/ecs/envs/bo/app_env_local_file.tf @@ -0,0 +1,53 @@ +data "aws_secretsmanager_secret_version" "db" { + secret_id = aws_secretsmanager_secret.db.id +} + +locals { + db_secret = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string) + db_host_from_rds = aws_db_instance.this.address + db_port_from_rds = aws_db_instance.this.port + + effective_db_host = local.db_host_from_rds != "" ? local.db_host_from_rds : try(local.db_secret.host, "") + effective_db_port = local.db_port_from_rds != 0 ? local.db_port_from_rds : try(local.db_secret.port, 3306) +} + +resource "local_file" "app_env_json" { + filename = "${path.module}/app_env.json" + + content = templatefile("${path.module}/templates/app_env.json.tmpl", { + rails_env = var.rails_env + rails_log_to_stdout = var.rails_log_to_stdout + rails_serve_static = var.rails_serve_static + puma_port = var.puma_port + web_concurrency = var.web_concurrency + rails_max_threads = var.rails_max_threads + rails_min_threads = var.rails_min_threads + + # handle null secret_key_base + secret_key_base = try(coalesce(var.secret_key_base, ""), "") + + # Database fields (read-only from Secrets Manager + RDS) + db_name = try(local.db_secret.database, var.db_name) + db_host = local.effective_db_host + db_port = local.effective_db_port + db_user = local.db_secret.username + db_password = local.db_secret.password + + # Office365 / SSO + office365_client_id = var.office365_client_id + office365_client_secret = var.office365_client_secret + office365_redirect_uri = var.office365_redirect_uri + office365_provider_url = var.office365_provider_url + + # Keycloak + keycloak_client_id = var.keycloak_client_id + keycloak_client_secret = var.keycloak_client_secret + keycloak_realm = var.keycloak_realm + keycloak_server_url = var.keycloak_server_url + + # SSL flag + use_ssl = var.use_ssl + }) + + file_permission = "0600" +} diff --git a/deployment/ecs/envs/bo/backend.hcl b/deployment/ecs/envs/bo/backend.hcl index 2a44389ee..149448264 100644 --- a/deployment/ecs/envs/bo/backend.hcl +++ b/deployment/ecs/envs/bo/backend.hcl @@ -1,4 +1,4 @@ -bucket = "mpath-terraform-state" +bucket = "mpath-terraform-remote-state" key = "mpath/ecs/bo/terraform.tfstate" region = "us-east-1" encrypt = true diff --git a/deployment/ecs/envs/bo/main.tf b/deployment/ecs/envs/bo/main.tf index bed591789..572aa5ad8 100644 --- a/deployment/ecs/envs/bo/main.tf +++ b/deployment/ecs/envs/bo/main.tf @@ -5,21 +5,24 @@ terraform { backend "s3" {} # init with: terraform init -backend-config=backend.hcl } -provider "aws" { region = var.aws_region } +provider "aws" { + region = var.aws_region +} locals { app_name = "mpath" - env = "bo" + env = var.environment } + # 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 + bucket = "mpath-terraform-remote-state" + key = "mpath/vpc/terraform.tfstate" + region = var.aws_region + encrypt = true } } @@ -35,56 +38,63 @@ locals { } } + +data "aws_secretsmanager_secret_version" "mpath_cert" { + secret_id = "/mpath/acm/cert-arn" +} + +locals { + _raw_cert_string = data.aws_secretsmanager_secret_version.mpath_cert.secret_string + _maybe_json = try(jsondecode(local._raw_cert_string), null) + _json_cert_arn = local._maybe_json == null ? "" : try(local._maybe_json.cert_arn, "") + acm_cert_from_sm = local._json_cert_arn != "" ? local._json_cert_arn : local._raw_cert_string + + # Final selection: explicit var wins; else Secrets Manager + selected_acm_cert_arn = var.acm_certificate_arn != "" ? var.acm_certificate_arn : local.acm_cert_from_sm + # Or, if you’re worried about whitespace: + # selected_acm_cert_arn = trimspace(var.acm_certificate_arn) != "" ? var.acm_certificate_arn : local.acm_cert_from_sm +} + + + 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 - + vpc_id = local.vpc_id + subnet_ids = local.private_subnet_ids + db_secret_arn = aws_secretsmanager_secret.db.arn + app_secret_arn = aws_secretsmanager_secret.app.arn # Container / service container_image = var.container_image - container_port = var.container_port # set to 8443 in terraform.tfvars + container_port = var.container_port 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/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 + acm_certificate_arn = local.selected_acm_cert_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 + 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" { @@ -98,3 +108,20 @@ resource "aws_wafv2_web_acl_association" "mpath_web_acl_assoc" { web_acl_arn = data.terraform_remote_state.root.outputs.waf_web_acl_arn } +resource "random_password" "secret_key_base" { + length = 64 + special = false +} + +resource "aws_secretsmanager_secret" "app" { + name = "mpath/bo/app" + description = "mPATH BO app env" +} + +resource "aws_secretsmanager_secret_version" "app" { + secret_id = aws_secretsmanager_secret.app.id + secret_string = jsonencode({ + SECRET_KEY_BASE = random_password.secret_key_base.result + # ...other keys... + }) +} diff --git a/deployment/ecs/envs/bo/outputs.tf b/deployment/ecs/envs/bo/outputs.tf new file mode 100644 index 000000000..c48093ff5 --- /dev/null +++ b/deployment/ecs/envs/bo/outputs.tf @@ -0,0 +1,31 @@ +output "rds_endpoint" { + description = "Full RDS endpoint (hostname:port)." + value = aws_db_instance.this.endpoint +} + +output "rds_sg_id" { + description = "Security Group ID attached to the RDS instance." + value = aws_security_group.rds_mysql.id +} + +output "rds_db_name" { + description = "Name of the database created." + value = var.db_name +} + +output "db_secret_arn" { + description = "Secrets Manager secret ARN with DB credentials." + value = aws_secretsmanager_secret.db.arn +} + +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 +} \ No newline at end of file diff --git a/deployment/ecs/envs/bo/rds.tf b/deployment/ecs/envs/bo/rds.tf new file mode 100644 index 000000000..a8fb4bd7e --- /dev/null +++ b/deployment/ecs/envs/bo/rds.tf @@ -0,0 +1,97 @@ +# Password (only used if var.db_password == null) +resource "random_password" "db" { + length = 24 + special = true + override_special = "!@#%^*-_=+" +} + +locals { + db_password_final = coalesce(var.db_password, random_password.db.result) + derived_ecs_tasks_sg_name = coalesce(var.ecs_tasks_sg_name, "mpath-${local.env}-ecs-tasks-sg") +} + +# DB Subnet Group (use private subnets from main.tf locals) +resource "aws_db_subnet_group" "this" { + name = "${var.db_identifier}-subnets" + subnet_ids = local.private_subnet_ids + tags = merge(local.tags, { Name = "${var.db_identifier}-subnets" }) +} + +# Security Group for RDS – allow MySQL from ECS tasks only +resource "aws_security_group" "rds_mysql" { + name = "${var.db_identifier}-sg" + description = "Allow MySQL from ECS tasks" + vpc_id = local.vpc_id + + ingress { + from_port = 3306 + to_port = 3306 + protocol = "tcp" + security_groups = [module.ecs_service.service_sg_id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(local.tags, { Name = "${var.db_identifier}-sg" }) +} + +# RDS Instance – MySQL 8, db.t3.micro, gp2, single-AZ +resource "aws_db_instance" "this" { + identifier = var.db_identifier + engine = "mysql" + engine_version = "8.0" # safe major pin + instance_class = "db.t3.micro" + + storage_type = "gp2" + allocated_storage = var.db_allocated_storage + max_allocated_storage = 100 + + db_subnet_group_name = aws_db_subnet_group.this.name + vpc_security_group_ids = [aws_security_group.rds_mysql.id] + port = 3306 + + # Credentials + username = var.db_username + password = local.db_password_final + db_name = var.db_name + + # Availability & security + multi_az = false + publicly_accessible = false + storage_encrypted = true + + # Backups & lifecycle + backup_retention_period = 7 + deletion_protection = false + skip_final_snapshot = false + + apply_immediately = true + + tags = merge(local.tags, { Name = var.db_identifier }) +} + +# Secrets Manager – JSON bundle for ECS injection +resource "aws_secretsmanager_secret" "db" { + name = var.secret_name + description = "mPATH ${upper(local.env)} DB connection" + kms_key_id = var.kms_key_id + tags = merge(local.tags, { Name = var.secret_name }) +} + +resource "aws_secretsmanager_secret_version" "db" { + secret_id = aws_secretsmanager_secret.db.id + secret_string = jsonencode({ + adapter = "mysql2" + username = var.db_username + password = local.db_password_final + host = aws_db_instance.this.address + port = 3306 + database = var.db_name + url = "mysql2://${var.db_username}:${local.db_password_final}@${aws_db_instance.this.address}:3306/${var.db_name}?encoding=utf8mb4&ssl_mode=required" + }) +} diff --git a/deployment/ecs/envs/bo/templates/app_env.json.tmpl b/deployment/ecs/envs/bo/templates/app_env.json.tmpl new file mode 100644 index 000000000..5abdc9d29 --- /dev/null +++ b/deployment/ecs/envs/bo/templates/app_env.json.tmpl @@ -0,0 +1,26 @@ +{ + "RAILS_ENV": "${rails_env}", + "RAILS_LOG_TO_STDOUT": ${rails_log_to_stdout}, + "RAILS_SERVE_STATIC_FILES": ${rails_serve_static}, + "PUMA_PORT": ${puma_port}, + "WEB_CONCURRENCY": ${web_concurrency}, + "RAILS_MAX_THREADS": ${rails_max_threads}, + "RAILS_MIN_THREADS": ${rails_min_threads}, + + "SECRET_KEY_BASE": "${secret_key_base}", + + "DATABASE_URL": "", + "DATABASE_NAME": "${db_name}", + "DATABASE_HOST": "${db_host}", + "DATABASE_PORT": ${db_port}, + "DATABASE_USER": "${db_user}", + "DATABASE_USERNAME": "${db_user}", + "DATABASE_PASSWORD": "${db_password}", + + "OFFICE365_CLIENT_ID": "${office365_client_id}", + "OFFICE365_CLIENT_SECRET": "${office365_client_secret}", + "OFFICE365_REDIRECT_URI": "${office365_redirect_uri}", + "OFFICE365_PROVIDER_URL": "${office365_provider_url}", + + "USE_SSL": ${use_ssl} +} diff --git a/deployment/ecs/envs/bo/terraform.tfvars b/deployment/ecs/envs/bo/terraform.tfvars index cffd85b93..6da5d5038 100644 --- a/deployment/ecs/envs/bo/terraform.tfvars +++ b/deployment/ecs/envs/bo/terraform.tfvars @@ -1,39 +1,47 @@ -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 +environment = "bo" + +# --- RDS (cheap single-AZ) --- +db_identifier = "mpath-bo-mysql" +db_name = "mpath_prod" +db_username = "mpath_admin" +db_password = null # leave null => auto-generate + store in Secrets Manager +db_allocated_storage = 20 # gp2 minimum +secret_name = "mpath/bo/db" +kms_key_id = null # or "arn:aws:kms:us-east-1:ACCOUNT:key/...." + +# If your ECS tasks SG name is NOT "mpath-bo-ecs-tasks-sg", uncomment & set: +# ecs_tasks_sg_name = "your-ecs-tasks-sg-name" + +# --- Container / service (unchanged) --- +container_image = "295669632222.dkr.ecr.us-east-1.amazonaws.com/microhealthllc/mpath-bo:latest-working" desired_count = 2 cpu = 1024 memory = 2048 -# Healthcheck -health_check_path = "/health" +# --- Healthcheck --- +health_check_path = "/users/sign_in" -# TLS / ALB -acm_certificate_arn = "" # ACM ARN -ssl_policy = "ELBSecurityPolicy-TLS-1-2-2017-01" +ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" alb_deletion_protection = true -# ECS deployment knobs -platform_version = "LATEST" -deployment_maximum_percent = 200 -deployment_minimum_healthy_percent = 50 -deployment_circuit_breaker_enabled = 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 - +log_retention_days = 30 waf_allowed_countries = ["US"] -# Tags +# --- Tags (add App/Env for SG auto-discovery) --- tags = { + App = "mPATH" + Env = "bo" Owner = "DevOps Team" CostCenter = "Engineering" Application = "mpath" } + +db_secret_arn = aws_secretsmanager_secret.db.arn diff --git a/deployment/ecs/envs/bo/variables.tf b/deployment/ecs/envs/bo/variables.tf index b58c58285..fc8276378 100644 --- a/deployment/ecs/envs/bo/variables.tf +++ b/deployment/ecs/envs/bo/variables.tf @@ -1,30 +1,289 @@ -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 } +# ===================================================================== +# Region +# ===================================================================== +variable "aws_region" { + description = "AWS region to deploy resources" + type = string + default = "us-east-1" +} + +# ===================================================================== +# Container / Service configuration +# ===================================================================== +variable "container_image" { + description = "ECR image URI for the ECS task" + type = string +} + +variable "container_port" { + description = "Port that the container exposes (must match app listener)" + type = number + default = 8443 +} + +variable "desired_count" { + description = "Desired number of running ECS tasks" + type = number + default = 2 +} + +variable "cpu" { + description = "CPU units for the ECS task (256, 512, 1024, etc.)" + type = number + default = 512 +} + +variable "memory" { + description = "Memory for the ECS task (in MB)" + type = number + default = 1024 +} + +variable "health_check_path" { + description = "Path used for ALB health checks" + type = string + default = "/users/sign_in" +} + +# ===================================================================== +# TLS / ALB configuration +# ===================================================================== +variable "acm_certificate_arn" { + description = "Optional ACM certificate ARN for the ALB. Leave blank to use Secrets Manager." + type = string + default = "" +} + +variable "ssl_policy" { + description = "SSL policy to use for the ALB HTTPS listener" + type = string + # Modern TLS 1.2/1.3 policy; adjust for legacy client support if needed + default = "ELBSecurityPolicy-TLS13-1-2-2021-06" +} + +# ===================================================================== +# ECS Deployment knobs +# ===================================================================== +variable "log_retention_days" { + description = "CloudWatch log retention (days)" + type = number + default = 30 +} + +variable "platform_version" { + description = "Fargate platform version to use" + type = string + default = "LATEST" +} + +variable "deployment_maximum_percent" { + description = "Upper limit of tasks running during deployment" + type = number + default = 200 +} + +variable "deployment_minimum_healthy_percent" { + description = "Lower limit of tasks running during deployment" + type = number + default = 50 +} + +variable "deployment_circuit_breaker_enabled" { + description = "Enable deployment circuit breaker" + type = bool + default = true +} + +variable "deployment_circuit_breaker_rollback" { + description = "Enable rollback if deployment fails" + type = bool + default = true +} + + +variable "environment" { + description = "Short env name (e.g., bo, qa, prod)" + type = string + default = "prod" +} + +variable "alb_deletion_protection" { + description = "Enable deletion protection on the ALB" + type = bool + default = true +} + +variable "waf_allowed_countries" { + description = "List of ISO country codes for WAF (unused unless explicitly wired)" + type = list(string) + default = [] +} + +############################ +# RDS-specific variables +############################ + +variable "db_identifier" { + description = "Unique identifier for the RDS instance." + type = string + default = "mpath-bo-mysql" +} + +variable "db_name" { + description = "Initial database name." + type = string + default = "mpath_prod" +} + +variable "db_username" { + description = "Database master or app username." + type = string + default = "mpath_admin" +} + +variable "db_password" { + description = "Optional DB password. If null, one will be auto-generated." + type = string + default = null + sensitive = true +} + +variable "db_allocated_storage" { + description = "Storage in GiB (gp2 minimum is 20)." + type = number + default = 20 +} + +variable "kms_key_id" { + description = "Optional KMS key for encrypting the secret." + type = string + default = null +} + +variable "secret_name" { + description = "Secrets Manager name for DB credentials bundle." + type = string + default = "mpath/bo/db" +} variable "tags" { - type = map(string) - default = {} + description = "Common tags to apply to RDS resources." + type = map(string) + default = { + App = "mPATH" + Env = "bo" + } } -variable "aws_region" { - default = "us-east-1" -} \ No newline at end of file +variable "ecs_tasks_sg_name" { + description = "Name of the ECS tasks security group to allow into MySQL. If null, uses a convention." + type = string + default = null +} + +# ── Rails Environment ───────────────────────────────────────── +variable "rails_env" { + type = string + default = "production" + description = "Rails environment (e.g., development, staging, production)" +} + +variable "rails_log_to_stdout" { + type = bool + default = true + description = "Enable Rails to log to STDOUT (for ECS/Docker environments)" +} + +variable "rails_serve_static" { + type = bool + default = true + description = "Serve static files directly from Rails" +} + +variable "puma_port" { + type = number + default = 3000 + description = "Port that Puma web server listens on" +} + +variable "web_concurrency" { + type = number + default = 2 + description = "Number of Puma worker processes" +} + +variable "rails_max_threads" { + type = number + default = 5 + description = "Maximum number of threads per Puma worker" +} + +variable "rails_min_threads" { + type = number + default = 2 + description = "Minimum number of threads per Puma worker" +} + +# ── Secrets & Keys ──────────────────────────────────────────── +variable "secret_key_base" { + type = string + default = null + description = "Rails SECRET_KEY_BASE; if null, Terraform generates and stores one in Secrets Manager" +} + +# ── Office 365 (SSO) ───────────────────────────────────────── +variable "office365_client_id" { + type = string + default = "" + description = "Office 365 application client ID" +} + +variable "office365_client_secret" { + type = string + default = "" + description = "Office 365 application client secret" +} + +variable "office365_redirect_uri" { + type = string + default = "" + description = "Redirect URI for Office 365 OAuth2 flow" +} + +variable "office365_provider_url" { + type = string + default = "" + description = "Office 365 provider or authorization endpoint" +} + +# ── Keycloak (SSO) ──────────────────────────────────────────── +variable "keycloak_client_id" { + type = string + default = "your-keycloak-client-id" + description = "Keycloak client ID" +} + +variable "keycloak_client_secret" { + type = string + default = "your-keycloak-client-secret" + description = "Keycloak client secret" +} + +variable "keycloak_realm" { + type = string + default = "your-keycloak-realm" + description = "Keycloak realm name" +} + +variable "keycloak_server_url" { + type = string + default = "https://xx" + description = "Base URL for your Keycloak server" +} + +# ── SSL / HTTPS ────────────────────────────────────────────── +variable "use_ssl" { + type = bool + default = false + description = "Enable SSL (HTTPS) for the application" +} diff --git a/deployment/ecs/main.tf b/deployment/ecs/main.tf index 6bb1ccf23..e5bafa6b9 100644 --- a/deployment/ecs/main.tf +++ b/deployment/ecs/main.tf @@ -1,18 +1,23 @@ terraform { + required_version = ">= 1.13.3" + backend "s3" {} required_providers { - aws = { source = "hashicorp/aws", version = "~> 5.0" } + aws = { + source = "hashicorp/aws" + version = "~> 5.95" # stable; avoids the 5.100.x ARM crash + } } - backend "s3" {} # init with -backend-config=backend-prod.hcl } + provider "aws" { region = var.aws_region } locals { app_name = "mpath" common_tags = merge(var.tags, { - Project = "mpath" + Project = "mpath" Environment = "shared-network" - ManagedBy = "Terraform" + ManagedBy = "Terraform" }) } @@ -21,7 +26,7 @@ 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 }) } # IGW @@ -57,8 +62,8 @@ resource "aws_subnet" "private" { # NAT (single-AZ cost saver) resource "aws_eip" "nat" { - domain = "vpc" - tags = merge(local.common_tags, { Name = "${var.nat_gateway_name}-eip" }) + domain = "vpc" + tags = merge(local.common_tags, { Name = "${var.nat_gateway_name}-eip" }) depends_on = [aws_internet_gateway.main] } @@ -72,13 +77,19 @@ resource "aws_nat_gateway" "main" { # 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 } + 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" }) } 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 } + 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" }) } @@ -94,18 +105,3 @@ resource "aws_route_table_association" "private" { route_table_id = aws_route_table.private.id } -# Outputs (env stacks will read these) -output "vpc_id" { - description = "Shared VPC ID" - value = aws_vpc.main.id -} - -output "private_subnet_ids" { - description = "Private subnet IDs for ECS tasks" - value = aws_subnet.private[*].id -} - -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 0870d1347..14c2a53fc 100644 --- a/deployment/ecs/modules/ecs/main.tf +++ b/deployment/ecs/modules/ecs/main.tf @@ -2,9 +2,33 @@ data "aws_region" "current" {} data "aws_caller_identity" "current" {} + # Toggle ALB creation inside this module locals { do_alb = var.create_alb + + # Use explicit ALB name if provided; else default to "-alb" + alb_name_effective = coalesce(var.alb_name, "${var.service_name}-alb") +} + +# Secrets to inject into the container (built from the ARNs passed in) +locals { + container_secrets = concat( + var.db_secret_arn != null ? [ + { name = "DB_USERNAME", valueFrom = "${var.db_secret_arn}:username::" }, + { name = "DB_PASSWORD", valueFrom = "${var.db_secret_arn}:password::" }, + { name = "DB_HOST", valueFrom = "${var.db_secret_arn}:host::" }, + { name = "DB_PORT", valueFrom = "${var.db_secret_arn}:port::" }, + { name = "DB_NAME", valueFrom = "${var.db_secret_arn}:database::" }, + { name = "DB_ADAPTER", valueFrom = "${var.db_secret_arn}:adapter::" }, + { name = "DATABASE_URL", valueFrom = "${var.db_secret_arn}:url::" } + ] : [], + var.app_secret_arn != null ? [ + # Rails core + { name = "SECRET_KEY_BASE", valueFrom = "${var.app_secret_arn}:SECRET_KEY_BASE::" }, + #{ name = "OFFICE365_CLIENT_ID", valueFrom = "${var.app_secret_arn}:OFFICE365_CLIENT_ID::" } + ] : [] + ) } resource "aws_ecs_cluster" "main" { @@ -54,11 +78,13 @@ resource "aws_iam_role" "ecs_task_role" { tags = var.tags } +# AWS-managed policy for pulling from ECR, writing logs, etc. 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" } +# Extra ECR actions (kept from your original) 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 @@ -78,6 +104,7 @@ resource "aws_iam_role_policy" "ecs_execution_role_ecr_policy" { }) } +# ---- ECS Task Definition (inject secrets) ---- resource "aws_ecs_task_definition" "app" { family = var.service_name requires_compatibilities = ["FARGATE"] @@ -95,7 +122,7 @@ resource "aws_ecs_task_definition" "app" { portMappings = [ { - containerPort = var.container_port # set to 8443 in env + containerPort = var.container_port protocol = "tcp" } ] @@ -107,6 +134,14 @@ resource "aws_ecs_task_definition" "app" { } ] + # Secrets injected by ECS at container start + secrets = local.container_secrets + # Run Rails seeds after short delay, then start the app + command = [ + "bash", "-lc", + "sleep 10 && echo 'Running Rails seeds...' && bundle exec rails db:seed RAILS_ENV=production || true; echo 'Starting Puma...' && bundle exec puma -C config/puma.rb" + ] + logConfiguration = { logDriver = "awslogs", options = { @@ -115,6 +150,7 @@ resource "aws_ecs_task_definition" "app" { 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"] @@ -130,6 +166,7 @@ resource "aws_ecs_task_definition" "app" { } +# ---- Networking SGs ---- resource "aws_security_group" "ecs_service" { name_prefix = "${var.service_name}-ecs-" vpc_id = var.vpc_id @@ -143,7 +180,7 @@ resource "aws_security_group" "ecs_service" { to_port = var.container_port protocol = "tcp" security_groups = [ingress.value] - description = "Allowed SG -> ECS container port" + description = "Allowed SG to ECS container port" } } @@ -155,12 +192,10 @@ resource "aws_security_group" "ecs_service" { to_port = var.container_port protocol = "tcp" security_groups = [ingress.value] - description = "Module ALB SG -> ECS container port" + description = "Module ALB SG to ECS container port" } } - - egress { from_port = 0 to_port = 0 @@ -173,7 +208,6 @@ resource "aws_security_group" "ecs_service" { lifecycle { create_before_destroy = true } } - # ALB SG resource "aws_security_group" "alb" { count = local.do_alb ? 1 : 0 @@ -181,28 +215,45 @@ resource "aws_security_group" "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"] } + 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 { + 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 + count = local.do_alb ? 1 : 0 + name = local.alb_name_effective + 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 + 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" @@ -239,8 +290,6 @@ resource "aws_lb_listener" "https" { type = "forward" target_group_arn = aws_lb_target_group.ecs[0].arn } - - tags = var.tags } # HTTP -> HTTPS redirect @@ -258,14 +307,10 @@ resource "aws_lb_listener" "http" { 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) + effective_tg_arn = var.target_group_arn != null ? var.target_group_arn : (local.do_alb ? aws_lb_target_group.ecs[0].arn : null) } resource "aws_ecs_service" "app" { @@ -290,7 +335,6 @@ resource "aws_ecs_service" "app" { rollback = var.deployment_circuit_breaker_rollback } - # Attach to TG if provided/created dynamic "load_balancer" { for_each = local.effective_tg_arn != null ? [1] : [] content { @@ -306,3 +350,40 @@ resource "aws_ecs_service" "app" { aws_iam_role_policy_attachment.ecs_execution_role_policy ] } + +# ---- Allow the ECS *execution role* to read Secrets Manager (and KMS if needed) ---- +locals { + secret_arns = compact([var.db_secret_arn, var.app_secret_arn]) + kms_key_arns_ = var.kms_key_arns # optional list; pass only if you used CMKs on the secrets +} + +resource "aws_iam_role_policy" "ecs_exec_secrets" { + count = length(local.secret_arns) > 0 ? 1 : 0 + name = "${var.service_name}-exec-secrets" + role = aws_iam_role.ecs_execution_role.name + + policy = jsonencode({ + Version = "2012-10-17", + Statement = concat( + [ + { + Sid = "ReadSecretsFromSecretsManager" + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ] + Resource = local.secret_arns + } + ], + length(local.kms_key_arns_) > 0 ? [ + { + Sid = "DecryptSecretsWithKMS" + Effect = "Allow" + Action = ["kms:Decrypt"] + Resource = local.kms_key_arns_ + } + ] : [] + ) + }) +} diff --git a/deployment/ecs/modules/ecs/outputs.tf b/deployment/ecs/modules/ecs/outputs.tf index 7dfc0acb7..f515f3592 100644 --- a/deployment/ecs/modules/ecs/outputs.tf +++ b/deployment/ecs/modules/ecs/outputs.tf @@ -89,3 +89,8 @@ output "target_group_arn_effective" { description = "Target Group ARN actually used by the ECS service" value = local.effective_tg_arn } + +output "service_sg_id" { + description = "Security Group ID attached to the ECS service/tasks" + value = aws_security_group.ecs_service.id +} diff --git a/deployment/ecs/modules/ecs/variables.tf b/deployment/ecs/modules/ecs/variables.tf index 7dfa4fc25..98ebbcbaf 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 = 8443 + default = 8443 } variable "desired_count" { @@ -123,6 +123,12 @@ variable "create_alb" { description = "Create ALB/TG/listeners inside the module" type = bool default = false + +validation { + condition = !var.create_alb || (length(var.public_subnet_ids) > 0 && length(trimspace(var.acm_certificate_arn)) > 0) + error_message = "When create_alb = true, you must provide public_subnet_ids and acm_certificate_arn." +} + } variable "public_subnet_ids" { @@ -140,11 +146,12 @@ variable "acm_certificate_arn" { variable "ssl_policy" { description = "TLS policy for HTTPS listener" type = string + # Consider: "ELBSecurityPolicy-TLS13-1-2-2021-06" for modern clients default = "ELBSecurityPolicy-TLS-1-2-2017-01" } variable "alb_name" { - description = "Name for the ALB (defaults to ${service_name}-alb)" + description = "Optional explicit ALB name; if null, the module will use -alb." type = string default = null } @@ -161,8 +168,25 @@ variable "allowed_source_sg_ids" { default = [] } +variable "target_group_arn" { + description = "Optional existing Target Group ARN to attach ECS service to; if null, the module-created TG is used." + type = string + default = null +} -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." +variable "db_secret_arn" { + type = string + default = null +} + +variable "app_secret_arn" { + type = string + default = null +} + + +variable "kms_key_arns" { + type = list(string) + default = [] + description = "Optional list of KMS key ARNs used to encrypt the secrets; grants kms:Decrypt to the execution role." } diff --git a/deployment/ecs/outputs.tf b/deployment/ecs/outputs.tf index 5c3cf77fd..b97b537ab 100644 --- a/deployment/ecs/outputs.tf +++ b/deployment/ecs/outputs.tf @@ -1,54 +1,3 @@ -output "ecs_cluster_name" { - description = "Name of the ECS cluster" - value = module.ecs.cluster_id -} - -output "ecs_service_name" { - description = "Name of the ECS service" - value = module.ecs.service_id -} - -output "task_definition_arn" { - description = "ARN of the ECS task definition" - value = module.ecs.task_definition_arn -} - -output "security_group_id" { - description = "Security group ID for the ECS service" - value = module.ecs.security_group_id -} - -output "log_group_name" { - description = "CloudWatch log group for the application" - value = module.ecs.log_group_name -} - -output "log_group_arn" { - description = "CloudWatch log group ARN for the application" - value = module.ecs.log_group_arn -} - -output "ecs_cluster_name_friendly" { - description = "Friendly name of the ECS cluster" - value = module.ecs.cluster_name -} - -output "ecs_service_name_friendly" { - description = "Friendly name of the ECS service" - value = module.ecs.service_name -} - -output "rds_endpoint" { - description = "RDS instance endpoint" - value = module.rds.db_instance_endpoint - sensitive = true -} - -output "rds_instance_id" { - description = "RDS instance ID" - value = module.rds.db_instance_id -} - output "vpc_id" { description = "ID of the VPC" value = aws_vpc.main.id @@ -64,31 +13,12 @@ output "private_subnet_ids" { value = aws_subnet.private[*].id } -output "alb_dns_name" { - description = "DNS name of the load balancer" - value = aws_lb.mpath_production_alb.dns_name -} - -output "alb_zone_id" { - description = "Zone ID of the load balancer" - value = aws_lb.mpath_production_alb.zone_id -} - -output "alb_arn" { - description = "ARN of the load balancer" - 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 -} - -# root/outputs.tf output "waf_web_acl_arn" { - value = aws_wafv2_web_acl.mpath_web_acl.arn + description = "ARN of the WAF Web ACL" + value = aws_wafv2_web_acl.mpath_web_acl.arn } output "waf_web_acl_id" { - value = aws_wafv2_web_acl.mpath_web_acl.id + description = "ID of the WAF Web ACL" + value = aws_wafv2_web_acl.mpath_web_acl.id } diff --git a/deployment/ecs/variables.tf b/deployment/ecs/variables.tf index 8b5f9dee9..f444218fc 100644 --- a/deployment/ecs/variables.tf +++ b/deployment/ecs/variables.tf @@ -1,5 +1,5 @@ variable "environment" { - description = "Environment name (e.g., Development, Staging, Production)" + description = "Environment name" type = string default = "Production" @@ -39,12 +39,8 @@ variable "vpc_name" { variable "vpc_cidr_block" { description = "The CIDR block for the VPC" - default = "192.168.29.0/24" -} - -variable "certificate_arn" { - description = "ARN of the ACM certificate for HTTPS listeners" type = string + default = "192.168.29.0/24" } variable "internet_gateway_name" { @@ -69,229 +65,14 @@ variable "nat_gateway_name" { } } -variable "aws_account_id" { - description = "AWS Account ID for ECR repository" - type = string -} - -variable "container_image_tag" { - description = "Container image tag" - type = string - default = "latest" -} - -variable "desired_count" { - description = "Desired number of ECS tasks" - type = number - default = 1 -} - -variable "cpu" { - description = "CPU units for the ECS task" - type = number - default = 512 -} - -variable "memory" { - description = "Memory for the ECS task in MB" - type = number - default = 1024 -} - -variable "database_url" { - description = "Database connection URL" - type = string - sensitive = true -} - -variable "secret_key" { - description = "Application secret key" - type = string - sensitive = true -} - -variable "bw_org_id" { - description = "Bitwarden Organization ID" - type = string - sensitive = true -} - -variable "bw_access_token" { - description = "Bitwarden Access Token" - type = string - sensitive = true -} - -variable "bw_project_id" { - description = "Bitwarden Project ID" - type = string - sensitive = true -} - -variable "postgres_database" { - type = string - description = "The name of the database" - sensitive = true -} - -variable "postgres_username" { - type = string - description = "The username for the database" - sensitive = true -} - -variable "postgres_password" { - type = string - description = "The password for the database" - sensitive = true -} - -variable "identifier" { - type = string - description = "The identifier for the RDS instance" - sensitive = true -} - -variable "instance_class" { - description = "The instance class to use for the RDS instance." - type = string - default = "db.m5.large" -} - -variable "rds_instance_class" { - description = "RDS instance class" - type = string - default = "db.m5.xlarge" - - validation { - condition = can(regex("^db\\.", var.rds_instance_class)) - error_message = "RDS instance class must start with 'db.'" - } -} - -variable "rds_allocated_storage" { - description = "RDS allocated storage in GB" - type = number - default = 250 - - validation { - condition = var.rds_allocated_storage >= 20 && var.rds_allocated_storage <= 65536 - error_message = "RDS allocated storage must be between 20 GB and 65536 GB." - } -} - -# RDS CloudWatch Logging Variables -variable "rds_enabled_cloudwatch_logs_exports" { - description = "List of log types to export to CloudWatch for RDS" - type = list(string) - default = ["postgresql"] -} - -variable "rds_performance_insights_enabled" { - description = "Enable Performance Insights for RDS" - type = bool - default = true -} - -variable "rds_performance_insights_retention_period" { - description = "Amount of time in days to retain Performance Insights data" - type = number - default = 7 -} - -variable "rds_monitoring_interval" { - description = "The interval for collecting enhanced monitoring metrics" - type = number - default = 60 -} - -variable "tags" { - description = "A map of tags to apply to all resources" - type = map(string) - default = {} -} - -variable "ecs_log_retention_days" { - description = "CloudWatch log retention period in days for ECS" - type = number - default = 30 -} - -variable "ecs_health_check_enabled" { - description = "Enable health checks for ECS containers" - type = bool - default = true -} - -variable "ecs_health_check_path" { - description = "Health check path for the application" - type = string - default = "/health" -} - -variable "ecs_assign_public_ip" { - description = "Assign public IP to ECS tasks" - type = bool - default = true -} - -variable "ecs_platform_version" { - description = "ECS platform version" - type = string - default = "LATEST" -} - -variable "ecs_deployment_maximum_percent" { - description = "Upper limit on the number of running tasks during deployment" - type = number - default = 200 -} - -variable "ecs_deployment_minimum_healthy_percent" { - description = "Lower limit on the number of running tasks during deployment" - type = number - default = 50 -} - -variable "ecs_deployment_circuit_breaker_enabled" { - description = "Enable deployment circuit breaker" - type = bool - default = true -} - -variable "ecs_deployment_circuit_breaker_rollback" { - description = "Enable rollback on deployment failure" - type = bool - default = true -} - -variable "ecs_container_insights_enabled" { - description = "Enable container insights for the ECS cluster" - type = bool - default = true -} - -variable "alb_deletion_protection" { - description = "Enable deletion protection for ALB" - type = bool - default = true -} - -variable "alb_ssl_policy" { - description = "SSL policy for HTTPS listener" - type = string - default = "ELBSecurityPolicy-TLS-1-2-2017-01" -} - variable "waf_allowed_countries" { description = "List of allowed country codes for WAF geo restriction" type = list(string) default = ["US"] } -# 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" } +variable "tags" { + description = "A map of tags to apply to all shared resources" + type = map(string) + default = {} } diff --git a/deployment/ecs/waf.tf b/deployment/ecs/waf.tf index efd036347..5db18ecf4 100644 --- a/deployment/ecs/waf.tf +++ b/deployment/ecs/waf.tf @@ -4,22 +4,29 @@ resource "aws_wafv2_web_acl" "mpath_web_acl" { description = "${var.environment} ${local.app_name} WebACL" scope = "REGIONAL" - default_action { allow {} } - + default_action { + allow {} + } + # 1) Allow-only list of countries; everything else is blocked rule { name = "block-non-allowed-countries" priority = 0 + statement { not_statement { statement { geo_match_statement { - country_codes = var.waf_allowed_countries # e.g., ["US"] + country_codes = var.waf_allowed_countries # e.g., ["US"] } } } } - action { block {} } + + action { + block {} + } + visibility_config { sampled_requests_enabled = true cloudwatch_metrics_enabled = true @@ -27,35 +34,47 @@ resource "aws_wafv2_web_acl" "mpath_web_acl" { } } - # 2) Block known bad IPs (very low false-positive) + # 2) AWS managed IP reputation (enforced) rule { name = "AWS-AWSManagedRulesAmazonIpReputationList" priority = 1 + statement { managed_rule_group_statement { - name = "AWSManagedRulesAmazonIpReputationList" vendor_name = "AWS" + name = "AWSManagedRulesAmazonIpReputationList" } } - override_action { none {} } # enforce block + + # 'none' = respect the rule group's native action (block) + override_action { + none {} + } + visibility_config { + sampled_requests_enabled = true cloudwatch_metrics_enabled = true metric_name = "AWS-AWSManagedRulesAmazonIpReputationList" - sampled_requests_enabled = true } } - # 3) Common rules in COUNT mode (observe first, then enforce later) + # 3) Common rules in COUNT mode (observe first) rule { name = "AWS-AWSManagedRulesCommonRuleSet" priority = 2 + statement { managed_rule_group_statement { vendor_name = "AWS" name = "AWSManagedRulesCommonRuleSet" } } - override_action { count {} } # low-risk: no blocking yet + + # 'count' = do not block yet; just record matches + override_action { + count {} + } + visibility_config { sampled_requests_enabled = true cloudwatch_metrics_enabled = true @@ -63,6 +82,7 @@ resource "aws_wafv2_web_acl" "mpath_web_acl" { } } + # Web ACL level visibility visibility_config { sampled_requests_enabled = true cloudwatch_metrics_enabled = true diff --git a/docker-compose.yaml b/docker-compose.yaml index 04de08e83..336c3c611 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,8 +1,5 @@ version: '3.8' -networks: - app_network: - services: app: build: @@ -11,48 +8,13 @@ services: container_name: app restart: always ports: - - "3000:3000" # Expose port 3000 for local access + - "8443:8443" env_file: - .env environment: RAILS_ENV: production - DATABASE_URL: mysql2://mpath_user:mpath_pass@mysql:3306/mpath - DATABASE_HOST: mysql - DATABASE_PORT: 3306 - DATABASE_NAME: mpath - DATABASE_USER: mpath_user - DATABASE_PASSWORD: mpath_pass - PUMA_PORT: 3000 # Match exposed port - RAILS_MAX_THREADS: 5 - RAILS_MIN_THREADS: 5 WEB_CONCURRENCY: 2 PUMA_PIDFILE: /var/www/mPATH/tmp/pids/server.pid - networks: - - app_network volumes: - .:/var/www/mPATH - depends_on: - - mysql entrypoint: ["/bin/bash", "/var/www/mPATH/docker/app/entrypoint.sh"] - - mysql: - build: - context: docker/mysql - dockerfile: Dockerfile - container_name: mysql - environment: - MYSQL_ROOT_PASSWORD: root_pass - MYSQL_DATABASE: mpath - MYSQL_USER: mpath_user - MYSQL_PASSWORD: mpath_pass - ports: - - "3306:3306" - volumes: - - mysql-data:/var/lib/mysql - - ./dump.sql:/docker-entrypoint-initdb.d/dump.sql - networks: - - app_network - restart: always - -volumes: - mysql-data: diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index 83c9a52ee..438b1d07a 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -1,63 +1,110 @@ -# Use the official Ruby image -FROM ruby:3.1.0 +# Builder stage for installing dependencies +FROM ruby:3.1.0 AS builder -# Set the working directory inside the container WORKDIR /var/www/mPATH -# Install system dependencies, including gosu (no Nginx here to keep it clean) +# Install system dependencies (build-essential for native gems like mysql2, Node.js for Webpacker) RUN apt-get update -qq && apt-get install -y \ + build-essential \ curl dirmngr gnupg apt-transport-https ca-certificates \ software-properties-common \ 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 - \ + && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ && apt-get install -y nodejs \ - && npm install -g yarn + && npm install -g yarn \ + && rm -rf /var/lib/apt/lists/* # Install Bundler RUN gem install bundler:2.3.26 -# Create puma user and group -RUN groupadd -r puma && useradd -r -g puma -d /var/www/mPATH puma +# Copy lockfiles for caching +COPY Gemfile Gemfile.lock package.json yarn.lock ./ + +# Bundle config and install (production only) +RUN bundle config set --local without 'development test' \ + && bundle install -# Copy the application source code +# Set production env for JS installs +ENV NODE_ENV=production + +# Install JS dependencies (production only) +RUN yarn install --production --frozen-lockfile + +# Install all Node dependencies +ENV NODE_ENV=production + +RUN npm install + +# Copy full app code COPY . . -# Set entrypoint permissions while still root -RUN chmod +x /var/www/mPATH/docker/app/entrypoint.sh +# Asset compilation stage (mimic runtime environment) +FROM builder AS asset-compiler + +WORKDIR /var/www/mPATH + +# Set production environment to match runtime +ENV RAILS_ENV=production NODE_ENV=production + +# Create puma user and group (needed for permissions in asset compilation) +RUN groupadd -r puma && useradd -r -g puma -d /var/www/mPATH puma + +# Set permissions as root 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 +# Switch to puma user for asset compilation (mimics runtime) USER puma -# Bundler config and install (writes into /usr/local/bundle which puma now owns) -RUN bundle config set --local without 'development test' \ - && bundle install +# Install Webpacker/Shakapacker (no-op if already installed) +RUN bundle exec rails webpacker:install || true \ + && bundle exec rails shakapacker:install || true + +# Precompile assets (use dummy secret; ensure config.assets.initialize_on_precompile = false in config/application.rb) +RUN SECRET_KEY_BASE=dummy bundle exec rails assets:clobber assets:precompile + +# Verify manifest.json exists (debugging step) +RUN test -f /var/www/mPATH/public/packs/manifest.json && echo "manifest.json created successfully" || { echo "Error: manifest.json not found"; exit 1; } -# Install JavaScript dependencies -RUN yarn install --silent || true +# Final runtime stage +FROM ruby:3.1.0 -# 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 +WORKDIR /var/www/mPATH -# 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 +# Install minimal runtime dependencies (libmariadb3 for mysql2 runtime) +RUN apt-get update -qq && apt-get install -y \ + curl dirmngr gnupg apt-transport-https ca-certificates \ + software-properties-common \ + default-mysql-client libmariadb3 wget \ + && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ + && apt-get install -y nodejs \ + && npm install -g yarn \ + && 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/* + +# Create puma user and group +RUN groupadd -r puma && useradd -r -g puma -d /var/www/mPATH puma + +# Copy bundled gems, app code, and precompiled assets from asset-compiler stage +COPY --from=asset-compiler --chown=puma:puma /usr/local/bundle /usr/local/bundle +COPY --from=asset-compiler --chown=puma:puma /var/www/mPATH /var/www/mPATH + +# Set permissions as root +USER root +RUN chmod +x /var/www/mPATH/docker/app/entrypoint.sh \ + && 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 puma user +USER puma -# Expose port for Puma (plain HTTP; TLS terminates at ALB) +# Expose port for Puma EXPOSE 8443 # Entrypoint -ENTRYPOINT ["/bin/bash", "/var/www/mPATH/docker/app/entrypoint.sh"] +ENTRYPOINT ["/bin/bash", "/var/www/mPATH/docker/app/entrypoint.sh"] \ No newline at end of file diff --git a/docker/app/entrypoint.sh b/docker/app/entrypoint.sh index f86aa3cfa..a1ea3ae79 100755 --- a/docker/app/entrypoint.sh +++ b/docker/app/entrypoint.sh @@ -7,15 +7,6 @@ PORT="${PORT:-8443}" 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 -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 # Optional: wait for DB (Lightsail) if requested if [[ "${DB_WAIT:-0}" == "1" ]]; then diff --git a/rebuild_docker.sh b/rebuild_docker.sh index f165019ad..7dee03208 100644 --- a/rebuild_docker.sh +++ b/rebuild_docker.sh @@ -1,16 +1,6 @@ #!/bin/bash -echo "Stopping and Removing Containers, Networks, and Volumes..." -docker compose down -v --remove-orphans +echo "Rebuilding" +docker compose build --no-cache -echo "Pruning Docker system (images, containers, volumes)..." -docker system prune -a --volumes -f - -echo "Removing old MySQL data and temporary files..." -rm -rf docker/mysql/data -rm -rf tmp/pids/server.pid - -echo "Rebuilding and Starting Docker Containers..." -docker compose build --no-cache && docker compose up -d - -echo "rebuild is done" +echo "Rebuild is done"