From 4a9f1475dd6196c17154790891cdafd7314aa8d4 Mon Sep 17 00:00:00 2001 From: Joseph Yousefpour Date: Fri, 26 Dec 2025 18:11:59 -0500 Subject: [PATCH] adding all environments --- deployment/ecs/backend-prod.hcl | 2 +- deployment/ecs/envs/bo/backend.hcl | 2 +- deployment/ecs/envs/bo/main.tf | 2 +- deployment/ecs/envs/bo/rds.tf | 39 ++- deployment/ecs/envs/bo/terraform.tfvars | 12 +- deployment/ecs/envs/dha/.gitignore | 2 + deployment/ecs/envs/dha/backend.hcl | 5 + deployment/ecs/envs/dha/main.tf | 137 ++++++++ deployment/ecs/envs/dha/outputs.tf | 31 ++ deployment/ecs/envs/dha/rds.tf | 116 +++++++ .../ecs/envs/dha/templates/app_env.json.tmpl | 26 ++ deployment/ecs/envs/dha/variables.tf | 315 ++++++++++++++++++ deployment/ecs/envs/dos-qa/.gitignore | 2 + deployment/ecs/envs/dos-qa/backend.hcl | 5 + deployment/ecs/envs/dos-qa/main.tf | 137 ++++++++ deployment/ecs/envs/dos-qa/outputs.tf | 31 ++ deployment/ecs/envs/dos-qa/rds.tf | 116 +++++++ .../envs/dos-qa/templates/app_env.json.tmpl | 26 ++ deployment/ecs/envs/dos-qa/variables.tf | 315 ++++++++++++++++++ deployment/ecs/envs/dos/.gitignore | 2 + deployment/ecs/envs/dos/backend.hcl | 5 + deployment/ecs/envs/dos/main.tf | 137 ++++++++ deployment/ecs/envs/dos/outputs.tf | 31 ++ deployment/ecs/envs/dos/rds.tf | 116 +++++++ .../ecs/envs/dos/templates/app_env.json.tmpl | 26 ++ deployment/ecs/envs/dos/variables.tf | 315 ++++++++++++++++++ deployment/ecs/envs/hhs/.gitignore | 2 + deployment/ecs/envs/hhs/backend.hcl | 5 + deployment/ecs/envs/hhs/main.tf | 137 ++++++++ deployment/ecs/envs/hhs/outputs.tf | 31 ++ deployment/ecs/envs/hhs/rds.tf | 116 +++++++ .../ecs/envs/hhs/templates/app_env.json.tmpl | 26 ++ deployment/ecs/envs/hhs/variables.tf | 315 ++++++++++++++++++ deployment/ecs/envs/mgt/backend.hcl | 2 +- deployment/ecs/envs/mgt/main.tf | 2 +- deployment/ecs/envs/mgt/outputs.tf | 4 + deployment/ecs/envs/qa/backend.hcl | 2 +- deployment/ecs/envs/qa/main.tf | 2 +- deployment/ecs/envs/qa/rds.tf | 39 ++- deployment/ecs/envs/qa/variables.tf | 4 +- deployment/ecs/modules/ecs/main.tf | 7 - deployment/ecs/terraform.tfvars | 2 +- deployment/ecs/waf.tf | 67 ++-- 43 files changed, 2639 insertions(+), 77 deletions(-) create mode 100644 deployment/ecs/envs/dha/.gitignore create mode 100644 deployment/ecs/envs/dha/backend.hcl create mode 100644 deployment/ecs/envs/dha/main.tf create mode 100644 deployment/ecs/envs/dha/outputs.tf create mode 100644 deployment/ecs/envs/dha/rds.tf create mode 100644 deployment/ecs/envs/dha/templates/app_env.json.tmpl create mode 100644 deployment/ecs/envs/dha/variables.tf create mode 100644 deployment/ecs/envs/dos-qa/.gitignore create mode 100644 deployment/ecs/envs/dos-qa/backend.hcl create mode 100644 deployment/ecs/envs/dos-qa/main.tf create mode 100644 deployment/ecs/envs/dos-qa/outputs.tf create mode 100644 deployment/ecs/envs/dos-qa/rds.tf create mode 100644 deployment/ecs/envs/dos-qa/templates/app_env.json.tmpl create mode 100644 deployment/ecs/envs/dos-qa/variables.tf create mode 100644 deployment/ecs/envs/dos/.gitignore create mode 100644 deployment/ecs/envs/dos/backend.hcl create mode 100644 deployment/ecs/envs/dos/main.tf create mode 100644 deployment/ecs/envs/dos/outputs.tf create mode 100644 deployment/ecs/envs/dos/rds.tf create mode 100644 deployment/ecs/envs/dos/templates/app_env.json.tmpl create mode 100644 deployment/ecs/envs/dos/variables.tf create mode 100644 deployment/ecs/envs/hhs/.gitignore create mode 100644 deployment/ecs/envs/hhs/backend.hcl create mode 100644 deployment/ecs/envs/hhs/main.tf create mode 100644 deployment/ecs/envs/hhs/outputs.tf create mode 100644 deployment/ecs/envs/hhs/rds.tf create mode 100644 deployment/ecs/envs/hhs/templates/app_env.json.tmpl create mode 100644 deployment/ecs/envs/hhs/variables.tf diff --git a/deployment/ecs/backend-prod.hcl b/deployment/ecs/backend-prod.hcl index a89f3aa6f..506537181 100644 --- a/deployment/ecs/backend-prod.hcl +++ b/deployment/ecs/backend-prod.hcl @@ -1,4 +1,4 @@ -bucket = "mpath-terraform-remote-state" +bucket = "mpath-prod-terraform-remote-state" key = "mpath/vpc/terraform.tfstate" region = "us-east-1" encrypt = true diff --git a/deployment/ecs/envs/bo/backend.hcl b/deployment/ecs/envs/bo/backend.hcl index 149448264..57b96d1ab 100644 --- a/deployment/ecs/envs/bo/backend.hcl +++ b/deployment/ecs/envs/bo/backend.hcl @@ -1,4 +1,4 @@ -bucket = "mpath-terraform-remote-state" +bucket = "mpath-prod-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 f13978880..65474a5e9 100644 --- a/deployment/ecs/envs/bo/main.tf +++ b/deployment/ecs/envs/bo/main.tf @@ -19,7 +19,7 @@ locals { data "terraform_remote_state" "root" { backend = "s3" config = { - bucket = "mpath-terraform-remote-state" + bucket = "mpath-prod-terraform-remote-state" key = "mpath/vpc/terraform.tfstate" region = var.aws_region encrypt = true diff --git a/deployment/ecs/envs/bo/rds.tf b/deployment/ecs/envs/bo/rds.tf index 054e884d9..0663593fe 100644 --- a/deployment/ecs/envs/bo/rds.tf +++ b/deployment/ecs/envs/bo/rds.tf @@ -1,8 +1,7 @@ -# Password (only used if var.db_password == null) resource "random_password" "db" { length = 24 special = true - override_special = "!@#%^*-_=+" + override_special = "!#%^*-_=+" } locals { @@ -10,7 +9,7 @@ locals { 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 @@ -26,9 +25,7 @@ resource "aws_security_group" "rds_mysql" { from_port = 3306 to_port = 3306 protocol = "tcp" - security_groups = [ - module.ecs_service.service_sg_id - ] + security_groups = [module.ecs_service.service_sg_id] } egress { @@ -41,12 +38,29 @@ resource "aws_security_group" "rds_mysql" { tags = merge(local.tags, { Name = "${var.db_identifier}-sg" }) } +data "terraform_remote_state" "mgt" { + backend = "s3" + config = { + bucket = "mpath-prod-terraform-remote-state" + key = "mpath/ecs/mgt/terraform.tfstate" + region = var.aws_region + encrypt = true + } +} + +resource "aws_security_group_rule" "allow_mysql_from_twingate" { + type = "ingress" + from_port = 3306 + to_port = 3306 + protocol = "tcp" + security_group_id = aws_security_group.rds_mysql.id + source_security_group_id = data.terraform_remote_state.mgt.outputs.twingate_service_sg_id +} -# 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 + engine_version = "8.0" instance_class = "db.t3.micro" storage_type = "gp2" @@ -70,14 +84,16 @@ resource "aws_db_instance" "this" { # Backups & lifecycle backup_retention_period = 7 deletion_protection = false - skip_final_snapshot = false + skip_final_snapshot = false apply_immediately = true tags = merge(local.tags, { Name = var.db_identifier }) } -# Secrets Manager – JSON bundle for ECS injection + + +# This creates the *secret container*. resource "aws_secretsmanager_secret" "db" { name = var.secret_name description = "mPATH ${upper(local.env)} DB connection" @@ -87,6 +103,7 @@ resource "aws_secretsmanager_secret" "db" { resource "aws_secretsmanager_secret_version" "db" { secret_id = aws_secretsmanager_secret.db.id + secret_string = jsonencode({ adapter = "mysql2" username = var.db_username @@ -94,6 +111,6 @@ resource "aws_secretsmanager_secret_version" "db" { 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" + url = "mysql2://${var.db_username}:${urlencode(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/terraform.tfvars b/deployment/ecs/envs/bo/terraform.tfvars index 59916a5cd..605376d2b 100644 --- a/deployment/ecs/envs/bo/terraform.tfvars +++ b/deployment/ecs/envs/bo/terraform.tfvars @@ -1,14 +1,13 @@ aws_region = "us-east-1" environment = "bo" microsoft_secret_path = "mpath/bo/microsoft" -twingate_secret_path = "mpath/bo/twingate" # --- 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 +db_password = null +db_allocated_storage = 20 secret_name = "mpath/bo/db" kms_key_id = null # or "arn:aws:kms:us-east-1:ACCOUNT:key/...." @@ -37,15 +36,16 @@ log_retention_days = 30 waf_allowed_countries = ["US"] -# --- Tags (add App/Env for SG auto-discovery) --- + tags = { App = "mPATH" Env = "bo" - Owner = "DevOps Team" + Owner = "Microhealth Platform Engineering Team" CostCenter = "Engineering" Application = "mpath" } db_secret_arn = aws_secretsmanager_secret.db.arn mpath_exec = false -custom_domain_name = "mpath-ecs-bo.microhealthllc.com" \ No newline at end of file +custom_domain_name = "mpath-ecs-bo.microhealthllc.com" +readonly_root_filesystem = false \ No newline at end of file diff --git a/deployment/ecs/envs/dha/.gitignore b/deployment/ecs/envs/dha/.gitignore new file mode 100644 index 000000000..54f13ebe4 --- /dev/null +++ b/deployment/ecs/envs/dha/.gitignore @@ -0,0 +1,2 @@ +app_env.json +app_env*.json diff --git a/deployment/ecs/envs/dha/backend.hcl b/deployment/ecs/envs/dha/backend.hcl new file mode 100644 index 000000000..eb7656ee0 --- /dev/null +++ b/deployment/ecs/envs/dha/backend.hcl @@ -0,0 +1,5 @@ +bucket = "mpath-prod-terraform-remote-state" +key = "mpath/ecs/dha/terraform.tfstate" +region = "us-east-1" +encrypt = true +use_lockfile = true diff --git a/deployment/ecs/envs/dha/main.tf b/deployment/ecs/envs/dha/main.tf new file mode 100644 index 000000000..0c0abdd71 --- /dev/null +++ b/deployment/ecs/envs/dha/main.tf @@ -0,0 +1,137 @@ +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 = var.environment +} + + +# Pull shared network from the ROOT stack +data "terraform_remote_state" "root" { + backend = "s3" + config = { + bucket = "mpath-prod-terraform-remote-state" + key = "mpath/vpc/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" + } +} + + +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 + 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 + desired_count = var.desired_count + cpu = var.cpu + memory = var.memory + health_check_path = var.health_check_path + mpath_exec = var.mpath_exec + readonly_root_filesystem = var.readonly_root_filesystem + custom_domain_name = var.custom_domain_name + environment_variables = { + RAILS_ENV = "production" + RAILS_SERVE_STATIC_FILES = "true" + NODE_ENV = "production" + } + + # 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 = 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 + microsoft_secret_path = var.microsoft_secret_path + tags = local.tags +} + + +# Discover this env’s ALB (created by module.ecs_service) +data "aws_lb" "dha_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.dha_alb.arn + 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/dha/app" + description = "mPATH dha 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/dha/outputs.tf b/deployment/ecs/envs/dha/outputs.tf new file mode 100644 index 000000000..184184634 --- /dev/null +++ b/deployment/ecs/envs/dha/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 "dha_alb_dns_name" { + value = module.ecs_service.alb_dns_name +} + +output "dha_target_group_arn" { + value = module.ecs_service.target_group_arn_effective +} + +output "dha_ecs_service_sg" { + value = module.ecs_service.security_group_id +} \ No newline at end of file diff --git a/deployment/ecs/envs/dha/rds.tf b/deployment/ecs/envs/dha/rds.tf new file mode 100644 index 000000000..0663593fe --- /dev/null +++ b/deployment/ecs/envs/dha/rds.tf @@ -0,0 +1,116 @@ +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") +} + + +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" }) +} + +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" }) +} + +data "terraform_remote_state" "mgt" { + backend = "s3" + config = { + bucket = "mpath-prod-terraform-remote-state" + key = "mpath/ecs/mgt/terraform.tfstate" + region = var.aws_region + encrypt = true + } +} + +resource "aws_security_group_rule" "allow_mysql_from_twingate" { + type = "ingress" + from_port = 3306 + to_port = 3306 + protocol = "tcp" + security_group_id = aws_security_group.rds_mysql.id + source_security_group_id = data.terraform_remote_state.mgt.outputs.twingate_service_sg_id +} + +resource "aws_db_instance" "this" { + identifier = var.db_identifier + engine = "mysql" + engine_version = "8.0" + 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 }) +} + + + +# This creates the *secret container*. +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}:${urlencode(local.db_password_final)}@${aws_db_instance.this.address}:3306/${var.db_name}?encoding=utf8mb4&ssl_mode=required" + }) +} diff --git a/deployment/ecs/envs/dha/templates/app_env.json.tmpl b/deployment/ecs/envs/dha/templates/app_env.json.tmpl new file mode 100644 index 000000000..5abdc9d29 --- /dev/null +++ b/deployment/ecs/envs/dha/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/dha/variables.tf b/deployment/ecs/envs/dha/variables.tf new file mode 100644 index 000000000..8588de52b --- /dev/null +++ b/deployment/ecs/envs/dha/variables.tf @@ -0,0 +1,315 @@ +# ===================================================================== +# Region +# ===================================================================== +variable "aws_region" { + description = "AWS region to deploy resources" + type = string + default = "us-east-1" +} + +variable "microsoft_secret_path" { + type = string + description = "Path to the Microsoft OAuth JSON secret in AWS Secrets Manager (e.g., mpath/bo/microsoft)" +} + +# ===================================================================== +# 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 = 1 +} + +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, dha, 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-dha-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/dha/db" +} + +variable "tags" { + description = "Common tags to apply to RDS resources." + type = map(string) + default = { + App = "mPATH" + Env = "dha" + } +} + +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" +} + +variable "mpath_exec" { + type = bool + default = false +} + +variable "custom_domain_name" { + description = "The domain allowed to access the ALB" + type = string +} + +variable "env" { + description = "Environment or deployment prefix" + type = string +} + +variable "readonly_root_filesystem" { + type = bool + default = false + description = "Whether to make the container's root filesystem read-only." +} \ No newline at end of file diff --git a/deployment/ecs/envs/dos-qa/.gitignore b/deployment/ecs/envs/dos-qa/.gitignore new file mode 100644 index 000000000..54f13ebe4 --- /dev/null +++ b/deployment/ecs/envs/dos-qa/.gitignore @@ -0,0 +1,2 @@ +app_env.json +app_env*.json diff --git a/deployment/ecs/envs/dos-qa/backend.hcl b/deployment/ecs/envs/dos-qa/backend.hcl new file mode 100644 index 000000000..a8768ee37 --- /dev/null +++ b/deployment/ecs/envs/dos-qa/backend.hcl @@ -0,0 +1,5 @@ +bucket = "mpath-prod-terraform-remote-state" +key = "mpath/ecs/dos-qa/terraform.tfstate" +region = "us-east-1" +encrypt = true +use_lockfile = true diff --git a/deployment/ecs/envs/dos-qa/main.tf b/deployment/ecs/envs/dos-qa/main.tf new file mode 100644 index 000000000..c3bd3e8a2 --- /dev/null +++ b/deployment/ecs/envs/dos-qa/main.tf @@ -0,0 +1,137 @@ +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 = var.environment +} + + +# Pull shared network from the ROOT stack +data "terraform_remote_state" "root" { + backend = "s3" + config = { + bucket = "mpath-prod-terraform-remote-state" + key = "mpath/vpc/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" + } +} + + +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 + 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 + desired_count = var.desired_count + cpu = var.cpu + memory = var.memory + health_check_path = var.health_check_path + mpath_exec = var.mpath_exec + readonly_root_filesystem = var.readonly_root_filesystem + custom_domain_name = var.custom_domain_name + environment_variables = { + RAILS_ENV = "production" + RAILS_SERVE_STATIC_FILES = "true" + NODE_ENV = "production" + } + + # 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 = 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 + microsoft_secret_path = var.microsoft_secret_path + tags = local.tags +} + + +# Discover this env’s ALB (created by module.ecs_service) +data "aws_lb" "dos-qa_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.dos-qa_alb.arn + 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/dos-qa/app" + description = "mPATH dos-qa 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/dos-qa/outputs.tf b/deployment/ecs/envs/dos-qa/outputs.tf new file mode 100644 index 000000000..9323a8330 --- /dev/null +++ b/deployment/ecs/envs/dos-qa/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 "dos-qa_alb_dns_name" { + value = module.ecs_service.alb_dns_name +} + +output "dos-qa_target_group_arn" { + value = module.ecs_service.target_group_arn_effective +} + +output "dos-qa_ecs_service_sg" { + value = module.ecs_service.security_group_id +} \ No newline at end of file diff --git a/deployment/ecs/envs/dos-qa/rds.tf b/deployment/ecs/envs/dos-qa/rds.tf new file mode 100644 index 000000000..0663593fe --- /dev/null +++ b/deployment/ecs/envs/dos-qa/rds.tf @@ -0,0 +1,116 @@ +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") +} + + +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" }) +} + +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" }) +} + +data "terraform_remote_state" "mgt" { + backend = "s3" + config = { + bucket = "mpath-prod-terraform-remote-state" + key = "mpath/ecs/mgt/terraform.tfstate" + region = var.aws_region + encrypt = true + } +} + +resource "aws_security_group_rule" "allow_mysql_from_twingate" { + type = "ingress" + from_port = 3306 + to_port = 3306 + protocol = "tcp" + security_group_id = aws_security_group.rds_mysql.id + source_security_group_id = data.terraform_remote_state.mgt.outputs.twingate_service_sg_id +} + +resource "aws_db_instance" "this" { + identifier = var.db_identifier + engine = "mysql" + engine_version = "8.0" + 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 }) +} + + + +# This creates the *secret container*. +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}:${urlencode(local.db_password_final)}@${aws_db_instance.this.address}:3306/${var.db_name}?encoding=utf8mb4&ssl_mode=required" + }) +} diff --git a/deployment/ecs/envs/dos-qa/templates/app_env.json.tmpl b/deployment/ecs/envs/dos-qa/templates/app_env.json.tmpl new file mode 100644 index 000000000..5abdc9d29 --- /dev/null +++ b/deployment/ecs/envs/dos-qa/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/dos-qa/variables.tf b/deployment/ecs/envs/dos-qa/variables.tf new file mode 100644 index 000000000..2e00aa17e --- /dev/null +++ b/deployment/ecs/envs/dos-qa/variables.tf @@ -0,0 +1,315 @@ +# ===================================================================== +# Region +# ===================================================================== +variable "aws_region" { + description = "AWS region to deploy resources" + type = string + default = "us-east-1" +} + +variable "microsoft_secret_path" { + type = string + description = "Path to the Microsoft OAuth JSON secret in AWS Secrets Manager (e.g., mpath/bo/microsoft)" +} + +# ===================================================================== +# 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 = 1 +} + +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, dos-dos, 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-dos-dos-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/dos-dos/db" +} + +variable "tags" { + description = "Common tags to apply to RDS resources." + type = map(string) + default = { + App = "mPATH" + Env = "dos-dos" + } +} + +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" +} + +variable "mpath_exec" { + type = bool + default = false +} + +variable "custom_domain_name" { + description = "The domain allowed to access the ALB" + type = string +} + +variable "env" { + description = "Environment or deployment prefix" + type = string +} + +variable "readonly_root_filesystem" { + type = bool + default = false + description = "Whether to make the container's root filesystem read-only." +} \ No newline at end of file diff --git a/deployment/ecs/envs/dos/.gitignore b/deployment/ecs/envs/dos/.gitignore new file mode 100644 index 000000000..54f13ebe4 --- /dev/null +++ b/deployment/ecs/envs/dos/.gitignore @@ -0,0 +1,2 @@ +app_env.json +app_env*.json diff --git a/deployment/ecs/envs/dos/backend.hcl b/deployment/ecs/envs/dos/backend.hcl new file mode 100644 index 000000000..d6c5429bd --- /dev/null +++ b/deployment/ecs/envs/dos/backend.hcl @@ -0,0 +1,5 @@ +bucket = "mpath-prod-terraform-remote-state" +key = "mpath/ecs/dos/terraform.tfstate" +region = "us-east-1" +encrypt = true +use_lockfile = true diff --git a/deployment/ecs/envs/dos/main.tf b/deployment/ecs/envs/dos/main.tf new file mode 100644 index 000000000..cb3e4b2b6 --- /dev/null +++ b/deployment/ecs/envs/dos/main.tf @@ -0,0 +1,137 @@ +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 = var.environment +} + + +# Pull shared network from the ROOT stack +data "terraform_remote_state" "root" { + backend = "s3" + config = { + bucket = "mpath-prod-terraform-remote-state" + key = "mpath/vpc/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" + } +} + + +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 + 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 + desired_count = var.desired_count + cpu = var.cpu + memory = var.memory + health_check_path = var.health_check_path + mpath_exec = var.mpath_exec + readonly_root_filesystem = var.readonly_root_filesystem + custom_domain_name = var.custom_domain_name + environment_variables = { + RAILS_ENV = "production" + RAILS_SERVE_STATIC_FILES = "true" + NODE_ENV = "production" + } + + # 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 = 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 + microsoft_secret_path = var.microsoft_secret_path + tags = local.tags +} + + +# Discover this env’s ALB (created by module.ecs_service) +data "aws_lb" "dos_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.dos_alb.arn + 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/dos/app" + description = "mPATH dos 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/dos/outputs.tf b/deployment/ecs/envs/dos/outputs.tf new file mode 100644 index 000000000..8230118bc --- /dev/null +++ b/deployment/ecs/envs/dos/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 "dos_alb_dns_name" { + value = module.ecs_service.alb_dns_name +} + +output "dos_target_group_arn" { + value = module.ecs_service.target_group_arn_effective +} + +output "dos_ecs_service_sg" { + value = module.ecs_service.security_group_id +} \ No newline at end of file diff --git a/deployment/ecs/envs/dos/rds.tf b/deployment/ecs/envs/dos/rds.tf new file mode 100644 index 000000000..0663593fe --- /dev/null +++ b/deployment/ecs/envs/dos/rds.tf @@ -0,0 +1,116 @@ +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") +} + + +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" }) +} + +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" }) +} + +data "terraform_remote_state" "mgt" { + backend = "s3" + config = { + bucket = "mpath-prod-terraform-remote-state" + key = "mpath/ecs/mgt/terraform.tfstate" + region = var.aws_region + encrypt = true + } +} + +resource "aws_security_group_rule" "allow_mysql_from_twingate" { + type = "ingress" + from_port = 3306 + to_port = 3306 + protocol = "tcp" + security_group_id = aws_security_group.rds_mysql.id + source_security_group_id = data.terraform_remote_state.mgt.outputs.twingate_service_sg_id +} + +resource "aws_db_instance" "this" { + identifier = var.db_identifier + engine = "mysql" + engine_version = "8.0" + 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 }) +} + + + +# This creates the *secret container*. +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}:${urlencode(local.db_password_final)}@${aws_db_instance.this.address}:3306/${var.db_name}?encoding=utf8mb4&ssl_mode=required" + }) +} diff --git a/deployment/ecs/envs/dos/templates/app_env.json.tmpl b/deployment/ecs/envs/dos/templates/app_env.json.tmpl new file mode 100644 index 000000000..5abdc9d29 --- /dev/null +++ b/deployment/ecs/envs/dos/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/dos/variables.tf b/deployment/ecs/envs/dos/variables.tf new file mode 100644 index 000000000..9cf13b4b1 --- /dev/null +++ b/deployment/ecs/envs/dos/variables.tf @@ -0,0 +1,315 @@ +# ===================================================================== +# Region +# ===================================================================== +variable "aws_region" { + description = "AWS region to deploy resources" + type = string + default = "us-east-1" +} + +variable "microsoft_secret_path" { + type = string + description = "Path to the Microsoft OAuth JSON secret in AWS Secrets Manager (e.g., mpath/bo/microsoft)" +} + +# ===================================================================== +# 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 = 1 +} + +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, dos, 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-dos-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/dos/db" +} + +variable "tags" { + description = "Common tags to apply to RDS resources." + type = map(string) + default = { + App = "mPATH" + Env = "dos" + } +} + +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" +} + +variable "mpath_exec" { + type = bool + default = false +} + +variable "custom_domain_name" { + description = "The domain allowed to access the ALB" + type = string +} + +variable "env" { + description = "Environment or deployment prefix" + type = string +} + +variable "readonly_root_filesystem" { + type = bool + default = false + description = "Whether to make the container's root filesystem read-only." +} \ No newline at end of file diff --git a/deployment/ecs/envs/hhs/.gitignore b/deployment/ecs/envs/hhs/.gitignore new file mode 100644 index 000000000..54f13ebe4 --- /dev/null +++ b/deployment/ecs/envs/hhs/.gitignore @@ -0,0 +1,2 @@ +app_env.json +app_env*.json diff --git a/deployment/ecs/envs/hhs/backend.hcl b/deployment/ecs/envs/hhs/backend.hcl new file mode 100644 index 000000000..017ad75db --- /dev/null +++ b/deployment/ecs/envs/hhs/backend.hcl @@ -0,0 +1,5 @@ +bucket = "mpath-prod-terraform-remote-state" +key = "mpath/ecs/hhs/terraform.tfstate" +region = "us-east-1" +encrypt = true +use_lockfile = true diff --git a/deployment/ecs/envs/hhs/main.tf b/deployment/ecs/envs/hhs/main.tf new file mode 100644 index 000000000..9a0d9c5ce --- /dev/null +++ b/deployment/ecs/envs/hhs/main.tf @@ -0,0 +1,137 @@ +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 = var.environment +} + + +# Pull shared network from the ROOT stack +data "terraform_remote_state" "root" { + backend = "s3" + config = { + bucket = "mpath-prod-terraform-remote-state" + key = "mpath/vpc/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" + } +} + + +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 + 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 + desired_count = var.desired_count + cpu = var.cpu + memory = var.memory + health_check_path = var.health_check_path + mpath_exec = var.mpath_exec + readonly_root_filesystem = var.readonly_root_filesystem + custom_domain_name = var.custom_domain_name + environment_variables = { + RAILS_ENV = "production" + RAILS_SERVE_STATIC_FILES = "true" + NODE_ENV = "production" + } + + # 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 = 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 + microsoft_secret_path = var.microsoft_secret_path + tags = local.tags +} + + +# Discover this env’s ALB (created by module.ecs_service) +data "aws_lb" "hhs_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.hhs_alb.arn + 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/hhs/app" + description = "mPATH hhs 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/hhs/outputs.tf b/deployment/ecs/envs/hhs/outputs.tf new file mode 100644 index 000000000..1abc6a648 --- /dev/null +++ b/deployment/ecs/envs/hhs/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 "hhs_alb_dns_name" { + value = module.ecs_service.alb_dns_name +} + +output "hhs_target_group_arn" { + value = module.ecs_service.target_group_arn_effective +} + +output "hhs_ecs_service_sg" { + value = module.ecs_service.security_group_id +} \ No newline at end of file diff --git a/deployment/ecs/envs/hhs/rds.tf b/deployment/ecs/envs/hhs/rds.tf new file mode 100644 index 000000000..0663593fe --- /dev/null +++ b/deployment/ecs/envs/hhs/rds.tf @@ -0,0 +1,116 @@ +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") +} + + +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" }) +} + +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" }) +} + +data "terraform_remote_state" "mgt" { + backend = "s3" + config = { + bucket = "mpath-prod-terraform-remote-state" + key = "mpath/ecs/mgt/terraform.tfstate" + region = var.aws_region + encrypt = true + } +} + +resource "aws_security_group_rule" "allow_mysql_from_twingate" { + type = "ingress" + from_port = 3306 + to_port = 3306 + protocol = "tcp" + security_group_id = aws_security_group.rds_mysql.id + source_security_group_id = data.terraform_remote_state.mgt.outputs.twingate_service_sg_id +} + +resource "aws_db_instance" "this" { + identifier = var.db_identifier + engine = "mysql" + engine_version = "8.0" + 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 }) +} + + + +# This creates the *secret container*. +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}:${urlencode(local.db_password_final)}@${aws_db_instance.this.address}:3306/${var.db_name}?encoding=utf8mb4&ssl_mode=required" + }) +} diff --git a/deployment/ecs/envs/hhs/templates/app_env.json.tmpl b/deployment/ecs/envs/hhs/templates/app_env.json.tmpl new file mode 100644 index 000000000..5abdc9d29 --- /dev/null +++ b/deployment/ecs/envs/hhs/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/hhs/variables.tf b/deployment/ecs/envs/hhs/variables.tf new file mode 100644 index 000000000..3fbf1137d --- /dev/null +++ b/deployment/ecs/envs/hhs/variables.tf @@ -0,0 +1,315 @@ +# ===================================================================== +# Region +# ===================================================================== +variable "aws_region" { + description = "AWS region to deploy resources" + type = string + default = "us-east-1" +} + +variable "microsoft_secret_path" { + type = string + description = "Path to the Microsoft OAuth JSON secret in AWS Secrets Manager (e.g., mpath/bo/microsoft)" +} + +# ===================================================================== +# 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 = 1 +} + +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, hhs, 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-hhs-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/hhs/db" +} + +variable "tags" { + description = "Common tags to apply to RDS resources." + type = map(string) + default = { + App = "mPATH" + Env = "hhs" + } +} + +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" +} + +variable "mpath_exec" { + type = bool + default = false +} + +variable "custom_domain_name" { + description = "The domain allowed to access the ALB" + type = string +} + +variable "env" { + description = "Environment or deployment prefix" + type = string +} + +variable "readonly_root_filesystem" { + type = bool + default = false + description = "Whether to make the container's root filesystem read-only." +} \ No newline at end of file diff --git a/deployment/ecs/envs/mgt/backend.hcl b/deployment/ecs/envs/mgt/backend.hcl index 5967e2c0a..c97337b33 100644 --- a/deployment/ecs/envs/mgt/backend.hcl +++ b/deployment/ecs/envs/mgt/backend.hcl @@ -1,4 +1,4 @@ -bucket = "mpath-terraform-remote-state" +bucket = "mpath-prod-terraform-remote-state" key = "mpath/ecs/mgt/terraform.tfstate" region = "us-east-1" encrypt = true diff --git a/deployment/ecs/envs/mgt/main.tf b/deployment/ecs/envs/mgt/main.tf index d188df5bf..bb9383612 100644 --- a/deployment/ecs/envs/mgt/main.tf +++ b/deployment/ecs/envs/mgt/main.tf @@ -17,7 +17,7 @@ provider "aws" { data "terraform_remote_state" "root" { backend = "s3" config = { - bucket = "mpath-terraform-remote-state" + bucket = "mpath-prod-terraform-remote-state" key = "mpath/vpc/terraform.tfstate" region = var.aws_region encrypt = true diff --git a/deployment/ecs/envs/mgt/outputs.tf b/deployment/ecs/envs/mgt/outputs.tf index e69de29bb..09a12745d 100644 --- a/deployment/ecs/envs/mgt/outputs.tf +++ b/deployment/ecs/envs/mgt/outputs.tf @@ -0,0 +1,4 @@ +output "twingate_service_sg_id" { + description = "Security Group ID for the Twingate ECS service/tasks" + value = module.twingate_connector.service_sg_id +} diff --git a/deployment/ecs/envs/qa/backend.hcl b/deployment/ecs/envs/qa/backend.hcl index a71de88b8..c4b033c82 100644 --- a/deployment/ecs/envs/qa/backend.hcl +++ b/deployment/ecs/envs/qa/backend.hcl @@ -1,4 +1,4 @@ -bucket = "mpath-terraform-remote-state" +bucket = "mpath-prod-terraform-remote-state" key = "mpath/ecs/qa/terraform.tfstate" region = "us-east-1" encrypt = true diff --git a/deployment/ecs/envs/qa/main.tf b/deployment/ecs/envs/qa/main.tf index 440541f39..024503684 100644 --- a/deployment/ecs/envs/qa/main.tf +++ b/deployment/ecs/envs/qa/main.tf @@ -19,7 +19,7 @@ locals { data "terraform_remote_state" "root" { backend = "s3" config = { - bucket = "mpath-terraform-remote-state" + bucket = "mpath-prod-terraform-remote-state" key = "mpath/vpc/terraform.tfstate" region = var.aws_region encrypt = true diff --git a/deployment/ecs/envs/qa/rds.tf b/deployment/ecs/envs/qa/rds.tf index 054e884d9..0663593fe 100644 --- a/deployment/ecs/envs/qa/rds.tf +++ b/deployment/ecs/envs/qa/rds.tf @@ -1,8 +1,7 @@ -# Password (only used if var.db_password == null) resource "random_password" "db" { length = 24 special = true - override_special = "!@#%^*-_=+" + override_special = "!#%^*-_=+" } locals { @@ -10,7 +9,7 @@ locals { 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 @@ -26,9 +25,7 @@ resource "aws_security_group" "rds_mysql" { from_port = 3306 to_port = 3306 protocol = "tcp" - security_groups = [ - module.ecs_service.service_sg_id - ] + security_groups = [module.ecs_service.service_sg_id] } egress { @@ -41,12 +38,29 @@ resource "aws_security_group" "rds_mysql" { tags = merge(local.tags, { Name = "${var.db_identifier}-sg" }) } +data "terraform_remote_state" "mgt" { + backend = "s3" + config = { + bucket = "mpath-prod-terraform-remote-state" + key = "mpath/ecs/mgt/terraform.tfstate" + region = var.aws_region + encrypt = true + } +} + +resource "aws_security_group_rule" "allow_mysql_from_twingate" { + type = "ingress" + from_port = 3306 + to_port = 3306 + protocol = "tcp" + security_group_id = aws_security_group.rds_mysql.id + source_security_group_id = data.terraform_remote_state.mgt.outputs.twingate_service_sg_id +} -# 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 + engine_version = "8.0" instance_class = "db.t3.micro" storage_type = "gp2" @@ -70,14 +84,16 @@ resource "aws_db_instance" "this" { # Backups & lifecycle backup_retention_period = 7 deletion_protection = false - skip_final_snapshot = false + skip_final_snapshot = false apply_immediately = true tags = merge(local.tags, { Name = var.db_identifier }) } -# Secrets Manager – JSON bundle for ECS injection + + +# This creates the *secret container*. resource "aws_secretsmanager_secret" "db" { name = var.secret_name description = "mPATH ${upper(local.env)} DB connection" @@ -87,6 +103,7 @@ resource "aws_secretsmanager_secret" "db" { resource "aws_secretsmanager_secret_version" "db" { secret_id = aws_secretsmanager_secret.db.id + secret_string = jsonencode({ adapter = "mysql2" username = var.db_username @@ -94,6 +111,6 @@ resource "aws_secretsmanager_secret_version" "db" { 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" + url = "mysql2://${var.db_username}:${urlencode(local.db_password_final)}@${aws_db_instance.this.address}:3306/${var.db_name}?encoding=utf8mb4&ssl_mode=required" }) } diff --git a/deployment/ecs/envs/qa/variables.tf b/deployment/ecs/envs/qa/variables.tf index 64af7375d..e63ca4b50 100644 --- a/deployment/ecs/envs/qa/variables.tf +++ b/deployment/ecs/envs/qa/variables.tf @@ -29,7 +29,7 @@ variable "container_port" { variable "desired_count" { description = "Desired number of running ECS tasks" type = number - default = 2 + default = 1 } variable "cpu" { @@ -310,6 +310,6 @@ variable "env" { variable "readonly_root_filesystem" { type = bool - default = true + default = false description = "Whether to make the container's root filesystem read-only." } \ No newline at end of file diff --git a/deployment/ecs/modules/ecs/main.tf b/deployment/ecs/modules/ecs/main.tf index e369237a8..ef4749a82 100644 --- a/deployment/ecs/modules/ecs/main.tf +++ b/deployment/ecs/modules/ecs/main.tf @@ -158,7 +158,6 @@ resource "aws_ecs_task_definition" "app" { protocol = "tcp" } ] - environment = [ for key, value in var.environment_variables : { name = key @@ -169,12 +168,6 @@ 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 = { diff --git a/deployment/ecs/terraform.tfvars b/deployment/ecs/terraform.tfvars index 131818d2a..34fa91215 100644 --- a/deployment/ecs/terraform.tfvars +++ b/deployment/ecs/terraform.tfvars @@ -12,7 +12,7 @@ waf_allowed_countries = ["US"] # Tags tags = { - Owner = "DevOps Team" + Owner = "Microhealth Platform Engineering" CostCenter = "Engineering" Application = "mpath" } \ No newline at end of file diff --git a/deployment/ecs/waf.tf b/deployment/ecs/waf.tf index a230a07b2..f2de5fe75 100644 --- a/deployment/ecs/waf.tf +++ b/deployment/ecs/waf.tf @@ -130,56 +130,59 @@ resource "aws_wafv2_web_acl" "mpath_web_acl" { } } - # 6) Rate-limit POST /users/sign_in + # 6) Block local username/password login (POST /users/sign_in) rule { - name = "rate-limit-signin-post" + name = "block-local-signin-post" priority = 6 + statement { - rate_based_statement { - limit = 2000 - aggregate_key_type = "IP" - scope_down_statement { - and_statement { - statement { - byte_match_statement { - search_string = "/users/sign_in" - positional_constraint = "EXACTLY" - field_to_match { - uri_path {} - } - text_transformation { - priority = 0 - type = "NONE" - } - } + and_statement { + statement { + byte_match_statement { + search_string = "/users/sign_in" + positional_constraint = "EXACTLY" + + field_to_match { + uri_path {} } - statement { - byte_match_statement { - search_string = "POST" - positional_constraint = "EXACTLY" - field_to_match { - method {} - } - text_transformation { - priority = 0 - type = "NONE" - } - } + + text_transformation { + priority = 0 + type = "NONE" + } + } + } + + statement { + byte_match_statement { + search_string = "POST" + positional_constraint = "EXACTLY" + + field_to_match { + method {} + } + + text_transformation { + priority = 0 + type = "NONE" } } } } } + action { block {} } + visibility_config { sampled_requests_enabled = true cloudwatch_metrics_enabled = true - metric_name = "rate-limit-signin-post" + metric_name = "block-local-signin-post" } } + # 7) Block /users/password/new entirely rule { name = "block-password-reset"