From b77a7782c5ba5c35eb34e250ab04de1e44229275 Mon Sep 17 00:00:00 2001 From: Joseph Yousefpour Date: Thu, 20 Nov 2025 13:28:46 -0500 Subject: [PATCH 1/2] adding qa ecs --- deployment/ecs/envs/qa/.gitignore | 2 + deployment/ecs/envs/qa/backend.hcl | 5 + deployment/ecs/envs/qa/main.tf | 159 +++++++++ deployment/ecs/envs/qa/outputs.tf | 31 ++ deployment/ecs/envs/qa/rds.tf | 97 ++++++ .../ecs/envs/qa/templates/app_env.json.tmpl | 26 ++ deployment/ecs/envs/qa/variables.tf | 321 ++++++++++++++++++ deployment/ecs/modules/ecs/main.tf | 2 +- .../ecs/modules/twingate-connector/main.tf | 12 +- .../modules/twingate-connector/variables.tf | 7 +- deployment/ecs/terraform.tfvars | 2 +- 11 files changed, 655 insertions(+), 9 deletions(-) create mode 100644 deployment/ecs/envs/qa/.gitignore create mode 100644 deployment/ecs/envs/qa/backend.hcl create mode 100644 deployment/ecs/envs/qa/main.tf create mode 100644 deployment/ecs/envs/qa/outputs.tf create mode 100644 deployment/ecs/envs/qa/rds.tf create mode 100644 deployment/ecs/envs/qa/templates/app_env.json.tmpl create mode 100644 deployment/ecs/envs/qa/variables.tf diff --git a/deployment/ecs/envs/qa/.gitignore b/deployment/ecs/envs/qa/.gitignore new file mode 100644 index 000000000..54f13ebe4 --- /dev/null +++ b/deployment/ecs/envs/qa/.gitignore @@ -0,0 +1,2 @@ +app_env.json +app_env*.json diff --git a/deployment/ecs/envs/qa/backend.hcl b/deployment/ecs/envs/qa/backend.hcl new file mode 100644 index 000000000..a71de88b8 --- /dev/null +++ b/deployment/ecs/envs/qa/backend.hcl @@ -0,0 +1,5 @@ +bucket = "mpath-terraform-remote-state" +key = "mpath/ecs/qa/terraform.tfstate" +region = "us-east-1" +encrypt = true +use_lockfile = true diff --git a/deployment/ecs/envs/qa/main.tf b/deployment/ecs/envs/qa/main.tf new file mode 100644 index 000000000..caa8bb6c0 --- /dev/null +++ b/deployment/ecs/envs/qa/main.tf @@ -0,0 +1,159 @@ +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-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 = true + 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" "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.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/qa/app" + description = "mPATH 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... + }) +} + + +module "twingate_connector" { + source = "../../modules/twingate-connector" + + # Network/cluster wiring + vpc_id = local.vpc_id + subnet_ids = local.private_subnet_ids + cluster_id = module.ecs_service.cluster_id + twingate_exec = var.twingate_exec + # Place the connector in private subnets, no public IP + assign_public_ip = false + desired_count = 1 + cpu = 1024 + memory = 2048 + + twingate_secret_path = var.twingate_secret_path + + readonly_root_filesystem = true + + + tags = merge(local.tags, { Service = "TwingateConnector" }) +} + diff --git a/deployment/ecs/envs/qa/outputs.tf b/deployment/ecs/envs/qa/outputs.tf new file mode 100644 index 000000000..0eac14cc1 --- /dev/null +++ b/deployment/ecs/envs/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 "qa_alb_dns_name" { + value = module.ecs_service.alb_dns_name +} + +output "qa_target_group_arn" { + value = module.ecs_service.target_group_arn_effective +} + +output "qa_ecs_service_sg" { + value = module.ecs_service.security_group_id +} \ No newline at end of file diff --git a/deployment/ecs/envs/qa/rds.tf b/deployment/ecs/envs/qa/rds.tf new file mode 100644 index 000000000..854f3c3b1 --- /dev/null +++ b/deployment/ecs/envs/qa/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,module.twingate_connector.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/qa/templates/app_env.json.tmpl b/deployment/ecs/envs/qa/templates/app_env.json.tmpl new file mode 100644 index 000000000..5abdc9d29 --- /dev/null +++ b/deployment/ecs/envs/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/qa/variables.tf b/deployment/ecs/envs/qa/variables.tf new file mode 100644 index 000000000..119c0fc89 --- /dev/null +++ b/deployment/ecs/envs/qa/variables.tf @@ -0,0 +1,321 @@ +# ===================================================================== +# 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 = 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-qa-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/qa/db" +} + +variable "tags" { + description = "Common tags to apply to RDS resources." + type = map(string) + default = { + App = "mPATH" + Env = "qa" + } +} + +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 "twingate_secret_path" { + description = "Optional path to the Twingate secret in AWS Secrets Manager (e.g., mpath/qa/twingate)." + type = string + default = "" +} + +variable "twingate_exec" { + type = bool + default = false +} + +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 +} diff --git a/deployment/ecs/modules/ecs/main.tf b/deployment/ecs/modules/ecs/main.tf index 553d61654..4ad333a81 100644 --- a/deployment/ecs/modules/ecs/main.tf +++ b/deployment/ecs/modules/ecs/main.tf @@ -417,7 +417,7 @@ locals { } 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 diff --git a/deployment/ecs/modules/twingate-connector/main.tf b/deployment/ecs/modules/twingate-connector/main.tf index f3c5fca0c..2e2a418eb 100644 --- a/deployment/ecs/modules/twingate-connector/main.tf +++ b/deployment/ecs/modules/twingate-connector/main.tf @@ -1,11 +1,11 @@ # CloudWatch Log Group for Twingate Connector resource "aws_cloudwatch_log_group" "twingate_logs" { - name = "/ecs/${var.service_name}" + name = "/ecs/${var.service_name}/${var.env}" retention_in_days = var.log_retention_days kms_key_id = var.enable_log_encryption ? var.log_kms_key_id : null tags = merge(var.tags, { - Name = "/ecs/${var.service_name}" + Name = "/ecs/${var.service_name}/${var.env}" Service = "TwingateConnector" }) } @@ -119,7 +119,7 @@ resource "aws_ecs_service" "twingate" { # IAM Role for ECS Execution resource "aws_iam_role" "ecs_execution_role" { - name = "${var.service_name}-ecs-execution-role" + name = "${var.service_name}-${var.env}-ecs-execution-role" assume_role_policy = jsonencode({ Version = "2012-10-17", @@ -137,7 +137,7 @@ resource "aws_iam_role" "ecs_execution_role" { # IAM Role for ECS Task resource "aws_iam_role" "ecs_task_role" { - name = "${var.service_name}-ecs-task-role" + name = "${var.service_name}-${var.env}-ecs-task-role" assume_role_policy = jsonencode({ Version = "2012-10-17", @@ -179,7 +179,7 @@ resource "aws_iam_role_policy_attachment" "ecs_task_role_ssm" { # Additional policy for ECS execution role to write to CloudWatch Logs resource "aws_iam_role_policy" "ecs_execution_role_logs_policy" { - name = "${var.service_name}-ecs-execution-logs-policy" + name = "${var.service_name}-${var.env}-ecs-execution-logs-policy" role = aws_iam_role.ecs_execution_role.id policy = jsonencode({ @@ -214,7 +214,7 @@ data "aws_iam_policy_document" "ecs_exec_sm" { } resource "aws_iam_policy" "ecs_exec_sm" { - name = "${var.service_name}-ecs-exec-secretsmanager" + name = "${var.service_name}-${var.env}-ecs-exec-secretsmanager" policy = data.aws_iam_policy_document.ecs_exec_sm.json } diff --git a/deployment/ecs/modules/twingate-connector/variables.tf b/deployment/ecs/modules/twingate-connector/variables.tf index 675bd3b00..305b70d7d 100644 --- a/deployment/ecs/modules/twingate-connector/variables.tf +++ b/deployment/ecs/modules/twingate-connector/variables.tf @@ -124,4 +124,9 @@ variable "twingate_exec" { description = "Enable exec" type = bool default = false -} \ No newline at end of file +} + +variable "env" { + description = "Environment or deployment prefix" + type = string +} diff --git a/deployment/ecs/terraform.tfvars b/deployment/ecs/terraform.tfvars index ac166f2e1..131818d2a 100644 --- a/deployment/ecs/terraform.tfvars +++ b/deployment/ecs/terraform.tfvars @@ -15,4 +15,4 @@ tags = { Owner = "DevOps Team" CostCenter = "Engineering" Application = "mpath" -} +} \ No newline at end of file From 3b45aa1ee85b60687b5ae40e9e36cf95a6b8c2b2 Mon Sep 17 00:00:00 2001 From: Joseph Yousefpour Date: Thu, 4 Dec 2025 23:24:55 -0500 Subject: [PATCH 2/2] cleaning up ecs code --- deployment/ecs/envs/bo/main.tf | 24 +------ deployment/ecs/envs/bo/rds.tf | 6 +- deployment/ecs/envs/bo/terraform.tfvars | 1 - deployment/ecs/envs/bo/variables.tf | 17 ++--- deployment/ecs/envs/mgt/.gitignore | 2 + deployment/ecs/envs/mgt/backend.hcl | 5 ++ deployment/ecs/envs/mgt/main.tf | 63 +++++++++++++++++++ deployment/ecs/envs/mgt/outputs.tf | 0 deployment/ecs/envs/mgt/variables.tf | 61 ++++++++++++++++++ deployment/ecs/envs/qa/main.tf | 24 +------ deployment/ecs/envs/qa/rds.tf | 6 +- deployment/ecs/envs/qa/variables.tf | 18 ++---- deployment/ecs/modules/ecs/main.tf | 5 +- deployment/ecs/modules/ecs/variables.tf | 1 - .../ecs/modules/twingate-connector/main.tf | 33 ++++++---- .../modules/twingate-connector/variables.tf | 16 +---- 16 files changed, 178 insertions(+), 104 deletions(-) create mode 100644 deployment/ecs/envs/mgt/.gitignore create mode 100644 deployment/ecs/envs/mgt/backend.hcl create mode 100644 deployment/ecs/envs/mgt/main.tf create mode 100644 deployment/ecs/envs/mgt/outputs.tf create mode 100644 deployment/ecs/envs/mgt/variables.tf diff --git a/deployment/ecs/envs/bo/main.tf b/deployment/ecs/envs/bo/main.tf index 14f4fc616..f13978880 100644 --- a/deployment/ecs/envs/bo/main.tf +++ b/deployment/ecs/envs/bo/main.tf @@ -74,7 +74,7 @@ module "ecs_service" { memory = var.memory health_check_path = var.health_check_path mpath_exec = var.mpath_exec - readonly_root_filesystem = true + readonly_root_filesystem = var.readonly_root_filesystem custom_domain_name = var.custom_domain_name environment_variables = { RAILS_ENV = "production" @@ -135,25 +135,3 @@ resource "aws_secretsmanager_secret_version" "app" { } -module "twingate_connector" { - source = "../../modules/twingate-connector" - - # Network/cluster wiring - vpc_id = local.vpc_id - subnet_ids = local.private_subnet_ids - cluster_id = module.ecs_service.cluster_id - twingate_exec = var.twingate_exec - # Place the connector in private subnets, no public IP - assign_public_ip = false - desired_count = 1 - cpu = 1024 - memory = 2048 - - twingate_secret_path = var.twingate_secret_path - - readonly_root_filesystem = true - - - tags = merge(local.tags, { Service = "TwingateConnector" }) -} - diff --git a/deployment/ecs/envs/bo/rds.tf b/deployment/ecs/envs/bo/rds.tf index 854f3c3b1..054e884d9 100644 --- a/deployment/ecs/envs/bo/rds.tf +++ b/deployment/ecs/envs/bo/rds.tf @@ -17,7 +17,6 @@ resource "aws_db_subnet_group" "this" { 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" @@ -27,7 +26,9 @@ resource "aws_security_group" "rds_mysql" { from_port = 3306 to_port = 3306 protocol = "tcp" - security_groups = [module.ecs_service.service_sg_id,module.twingate_connector.service_sg_id] + security_groups = [ + module.ecs_service.service_sg_id + ] } egress { @@ -40,6 +41,7 @@ resource "aws_security_group" "rds_mysql" { 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 diff --git a/deployment/ecs/envs/bo/terraform.tfvars b/deployment/ecs/envs/bo/terraform.tfvars index 18a8a1dce..59916a5cd 100644 --- a/deployment/ecs/envs/bo/terraform.tfvars +++ b/deployment/ecs/envs/bo/terraform.tfvars @@ -47,6 +47,5 @@ tags = { } db_secret_arn = aws_secretsmanager_secret.db.arn -twingate_exec = false mpath_exec = false custom_domain_name = "mpath-ecs-bo.microhealthllc.com" \ No newline at end of file diff --git a/deployment/ecs/envs/bo/variables.tf b/deployment/ecs/envs/bo/variables.tf index 0b188e872..8297dda32 100644 --- a/deployment/ecs/envs/bo/variables.tf +++ b/deployment/ecs/envs/bo/variables.tf @@ -294,17 +294,6 @@ variable "use_ssl" { } -variable "twingate_secret_path" { - description = "Optional path to the Twingate secret in AWS Secrets Manager (e.g., mpath/bo/twingate)." - type = string - default = "" -} - -variable "twingate_exec" { - type = bool - default = false -} - variable "mpath_exec" { type = bool default = false @@ -314,3 +303,9 @@ variable "custom_domain_name" { description = "The domain allowed to access the ALB" type = string } + +variable "readonly_root_filesystem" { + type = bool + default = true + description = "Whether to make the container's root filesystem read-only." +} \ No newline at end of file diff --git a/deployment/ecs/envs/mgt/.gitignore b/deployment/ecs/envs/mgt/.gitignore new file mode 100644 index 000000000..54f13ebe4 --- /dev/null +++ b/deployment/ecs/envs/mgt/.gitignore @@ -0,0 +1,2 @@ +app_env.json +app_env*.json diff --git a/deployment/ecs/envs/mgt/backend.hcl b/deployment/ecs/envs/mgt/backend.hcl new file mode 100644 index 000000000..5967e2c0a --- /dev/null +++ b/deployment/ecs/envs/mgt/backend.hcl @@ -0,0 +1,5 @@ +bucket = "mpath-terraform-remote-state" +key = "mpath/ecs/mgt/terraform.tfstate" +region = "us-east-1" +encrypt = true +use_lockfile = true diff --git a/deployment/ecs/envs/mgt/main.tf b/deployment/ecs/envs/mgt/main.tf new file mode 100644 index 000000000..d188df5bf --- /dev/null +++ b/deployment/ecs/envs/mgt/main.tf @@ -0,0 +1,63 @@ +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 +} + +# Pull shared network from the ROOT stack +data "terraform_remote_state" "root" { + backend = "s3" + config = { + bucket = "mpath-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 = { + ManagedBy = "Terraform" + } +} + +module "twingate_connector" { + source = "../../modules/twingate-connector" + + # Network wiring + vpc_id = local.vpc_id + subnet_ids = local.private_subnet_ids + + + # Place the connector in private subnets, no public IP + assign_public_ip = false + + # Task sizing + desired_count = 1 + cpu = 1024 + memory = 2048 + env = var.env + + # Secrets + twingate_secret_path = var.twingate_secret_path + + # Hardening + readonly_root_filesystem = var.readonly_root_filesystem + + tags = merge(local.tags, { + Service = "TwingateConnector" + }) +} diff --git a/deployment/ecs/envs/mgt/outputs.tf b/deployment/ecs/envs/mgt/outputs.tf new file mode 100644 index 000000000..e69de29bb diff --git a/deployment/ecs/envs/mgt/variables.tf b/deployment/ecs/envs/mgt/variables.tf new file mode 100644 index 000000000..21c99d102 --- /dev/null +++ b/deployment/ecs/envs/mgt/variables.tf @@ -0,0 +1,61 @@ +variable "aws_region" { + description = "AWS region to deploy resources" + type = string + default = "us-east-1" +} +variable "twingate_secret_path" { + description = "Path to the Twingate secret in AWS Secrets Manager" + type = string +} + +# Optional KMS key(s) for encrypting secrets (used by ECS exec policy, etc.) +variable "kms_key_id" { + description = "Optional KMS key for encrypting the RDS secret or other app secrets." + type = string + default = null +} + +variable "kms_key_arns" { + description = "Optional list of KMS key ARNs used to encrypt Secrets Manager secrets." + type = list(string) + default = [] +} + + + +variable "enable_log_encryption" { + description = "Whether to enable KMS encryption on CloudWatch Logs." + type = bool + default = false +} + +variable "log_kms_key_id" { + description = "KMS key ID/ARN to use for CloudWatch Logs encryption when enable_log_encryption is true." + type = string + default = null +} + + +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 "env" { + description = "Short env name (e.g., bo, qa, prod)" + type = string +} + +variable "readonly_root_filesystem" { + type = bool + default = true + description = "Whether to make the container's root filesystem read-only." +} \ No newline at end of file diff --git a/deployment/ecs/envs/qa/main.tf b/deployment/ecs/envs/qa/main.tf index caa8bb6c0..440541f39 100644 --- a/deployment/ecs/envs/qa/main.tf +++ b/deployment/ecs/envs/qa/main.tf @@ -74,7 +74,7 @@ module "ecs_service" { memory = var.memory health_check_path = var.health_check_path mpath_exec = var.mpath_exec - readonly_root_filesystem = true + readonly_root_filesystem = var.readonly_root_filesystem custom_domain_name = var.custom_domain_name environment_variables = { RAILS_ENV = "production" @@ -135,25 +135,3 @@ resource "aws_secretsmanager_secret_version" "app" { } -module "twingate_connector" { - source = "../../modules/twingate-connector" - - # Network/cluster wiring - vpc_id = local.vpc_id - subnet_ids = local.private_subnet_ids - cluster_id = module.ecs_service.cluster_id - twingate_exec = var.twingate_exec - # Place the connector in private subnets, no public IP - assign_public_ip = false - desired_count = 1 - cpu = 1024 - memory = 2048 - - twingate_secret_path = var.twingate_secret_path - - readonly_root_filesystem = true - - - tags = merge(local.tags, { Service = "TwingateConnector" }) -} - diff --git a/deployment/ecs/envs/qa/rds.tf b/deployment/ecs/envs/qa/rds.tf index 854f3c3b1..054e884d9 100644 --- a/deployment/ecs/envs/qa/rds.tf +++ b/deployment/ecs/envs/qa/rds.tf @@ -17,7 +17,6 @@ resource "aws_db_subnet_group" "this" { 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" @@ -27,7 +26,9 @@ resource "aws_security_group" "rds_mysql" { from_port = 3306 to_port = 3306 protocol = "tcp" - security_groups = [module.ecs_service.service_sg_id,module.twingate_connector.service_sg_id] + security_groups = [ + module.ecs_service.service_sg_id + ] } egress { @@ -40,6 +41,7 @@ resource "aws_security_group" "rds_mysql" { 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 diff --git a/deployment/ecs/envs/qa/variables.tf b/deployment/ecs/envs/qa/variables.tf index 119c0fc89..64af7375d 100644 --- a/deployment/ecs/envs/qa/variables.tf +++ b/deployment/ecs/envs/qa/variables.tf @@ -293,18 +293,6 @@ variable "use_ssl" { description = "Enable SSL (HTTPS) for the application" } - -variable "twingate_secret_path" { - description = "Optional path to the Twingate secret in AWS Secrets Manager (e.g., mpath/qa/twingate)." - type = string - default = "" -} - -variable "twingate_exec" { - type = bool - default = false -} - variable "mpath_exec" { type = bool default = false @@ -319,3 +307,9 @@ variable "env" { description = "Environment or deployment prefix" type = string } + +variable "readonly_root_filesystem" { + type = bool + default = true + 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 4ad333a81..e369237a8 100644 --- a/deployment/ecs/modules/ecs/main.tf +++ b/deployment/ecs/modules/ecs/main.tf @@ -165,10 +165,7 @@ resource "aws_ecs_task_definition" "app" { value = value } ] - - linuxParameters = { - readonlyRootFilesystem = var.readonly_root_filesystem - } + readonlyRootFilesystem = var.readonly_root_filesystem # Secrets injected by ECS at container start secrets = local.container_secrets diff --git a/deployment/ecs/modules/ecs/variables.tf b/deployment/ecs/modules/ecs/variables.tf index 5615b5bd8..b3c3a2b7a 100644 --- a/deployment/ecs/modules/ecs/variables.tf +++ b/deployment/ecs/modules/ecs/variables.tf @@ -194,7 +194,6 @@ variable "kms_key_arns" { variable "microsoft_secret_path" { type = string description = "Path to the Microsoft OAuth JSON secret in AWS Secrets Manager" - default = null } variable "readonly_root_filesystem" { diff --git a/deployment/ecs/modules/twingate-connector/main.tf b/deployment/ecs/modules/twingate-connector/main.tf index 2e2a418eb..2db0b7551 100644 --- a/deployment/ecs/modules/twingate-connector/main.tf +++ b/deployment/ecs/modules/twingate-connector/main.tf @@ -1,3 +1,7 @@ +# Data sources +data "aws_region" "current" {} +data "aws_caller_identity" "current" {} + # CloudWatch Log Group for Twingate Connector resource "aws_cloudwatch_log_group" "twingate_logs" { name = "/ecs/${var.service_name}/${var.env}" @@ -15,6 +19,18 @@ data "aws_secretsmanager_secret" "twingate" { name = var.twingate_secret_path } +# ECS Cluster for Twingate Connector +resource "aws_ecs_cluster" "twingate" { + name = "${var.service_name}-${var.env}-cluster" + + setting { + name = "containerInsights" + value = "enabled" + } + + tags = var.tags +} + # ECS Task Definition for Twingate Connector resource "aws_ecs_task_definition" "twingate" { family = var.service_name @@ -33,17 +49,15 @@ resource "aws_ecs_task_definition" "twingate" { memory = var.memory cpu = var.cpu - # readonlyRootFilesystem must be under linuxParameters - linuxParameters = { - readonlyRootFilesystem = var.readonly_root_filesystem - }, + + readonlyRootFilesystem = var.readonly_root_filesystem secrets = [ { name = "TWINGATE_NETWORK", valueFrom = "${data.aws_secretsmanager_secret.twingate.arn}:TWINGATE_NETWORK::" }, { name = "TWINGATE_ACCESS_TOKEN", valueFrom = "${data.aws_secretsmanager_secret.twingate.arn}:TWINGATE_ACCESS_TOKEN::" }, { name = "TWINGATE_REFRESH_TOKEN", valueFrom = "${data.aws_secretsmanager_secret.twingate.arn}:TWINGATE_REFRESH_TOKEN::" }, { name = "TWINGATE_LABEL_DEPLOYED_BY", valueFrom = "${data.aws_secretsmanager_secret.twingate.arn}:TWINGATE_LABEL_DEPLOYED_BY::" } - ], + ] logConfiguration = { logDriver = "awslogs" @@ -53,7 +67,6 @@ resource "aws_ecs_task_definition" "twingate" { awslogs-stream-prefix = "twingate-connector" } } - # Twingate connector doesn't expose any ports } ]) @@ -88,12 +101,12 @@ resource "aws_security_group" "twingate_connector" { # ECS Service for Twingate Connector resource "aws_ecs_service" "twingate" { name = var.service_name - cluster = var.cluster_id + cluster = aws_ecs_cluster.twingate.id + task_definition = aws_ecs_task_definition.twingate.arn desired_count = var.desired_count launch_type = "FARGATE" platform_version = var.platform_version - enable_execute_command = var.twingate_exec network_configuration { subnets = var.subnet_ids @@ -222,7 +235,3 @@ resource "aws_iam_role_policy_attachment" "ecs_exec_attach_sm" { role = aws_iam_role.ecs_execution_role.name policy_arn = aws_iam_policy.ecs_exec_sm.arn } - -# Data sources -data "aws_region" "current" {} -data "aws_caller_identity" "current" {} diff --git a/deployment/ecs/modules/twingate-connector/variables.tf b/deployment/ecs/modules/twingate-connector/variables.tf index 305b70d7d..0a7c16af1 100644 --- a/deployment/ecs/modules/twingate-connector/variables.tf +++ b/deployment/ecs/modules/twingate-connector/variables.tf @@ -28,11 +28,6 @@ variable "desired_count" { default = 1 } -variable "cluster_id" { - description = "ECS cluster ID where the Twingate connector will be deployed" - type = string -} - variable "vpc_id" { description = "VPC ID where the Twingate connector will be deployed" type = string @@ -116,17 +111,12 @@ variable "tags" { } variable "twingate_secret_path" { - description = "Path to the Twingate JSON secret in AWS Secrets Manager (e.g., mpath/bo/twingate)." + description = "Path to the Twingate JSON secret in AWS Secrets Manager" type = string } -variable "twingate_exec" { - description = "Enable exec" - type = bool - default = false -} variable "env" { - description = "Environment or deployment prefix" + description = "Short env name" type = string -} +} \ No newline at end of file