From dcb24461d7a80536827954f67b7e2d11b81ef579 Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 7 Apr 2026 11:09:55 -0400 Subject: [PATCH 1/9] Enable service connect. --- terraform/modules/alb/variables.tf | 0 terraform/modules/service/main.tf | 27 ++++++++++++++++++++++ terraform/modules/service/variables.tf | 32 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 terraform/modules/alb/variables.tf diff --git a/terraform/modules/alb/variables.tf b/terraform/modules/alb/variables.tf new file mode 100644 index 00000000..e69de29b diff --git a/terraform/modules/service/main.tf b/terraform/modules/service/main.tf index 535d8b96..f97e8626 100644 --- a/terraform/modules/service/main.tf +++ b/terraform/modules/service/main.tf @@ -1,6 +1,15 @@ locals { service_name = var.service_name_override != null ? var.service_name_override : var.platform.service service_name_full = "${var.platform.app}-${var.platform.env}-${local.service_name}" + + # Derive the primary container port from port_mappings for Service Connect fallback + primary_container_port = try( + [for pm in coalesce(var.port_mappings, []) : pm.containerPort if pm.containerPort != null][0], + null + ) + + # Service Connect port: explicit override → first containerPort in port_mappings → null + sc_port = coalesce(var.service_connect_port, local.primary_container_port) } resource "aws_ecs_task_definition" "this" { @@ -92,6 +101,24 @@ resource "aws_ecs_service" "this" { } } + dynamic "service_connect_configuration" { + for_each = var.enable_ecs_service_connect ? [1] : [] + content { + enabled = true + namespace = var.service_connect_namespace + + service { + # Must match the `name` field on the relevant entry in var.port_mappings + port_name = var.service_connect_port_name + + client_alias { + port = local.sc_port + dns_name = local.service_name + } + } + } + } + deployment_minimum_healthy_percent = 100 health_check_grace_period_seconds = var.health_check_grace_period_seconds } diff --git a/terraform/modules/service/variables.tf b/terraform/modules/service/variables.tf index 1e404b7c..9999b624 100644 --- a/terraform/modules/service/variables.tf +++ b/terraform/modules/service/variables.tf @@ -3,6 +3,38 @@ variable "cluster_arn" { type = string } +# ------------------------------------------------------- +# ECS Service Connect (optional) +# ------------------------------------------------------- +variable "enable_ecs_service_connect" { + description = "Enables ECS Service Connect so other services in the namespace can reach this one." + type = bool + default = false +} + +# TODO: Evaluate whether this should be set at the cluster/stack level and passed down +variable "service_connect_namespace" { + type = string + default = null + description = "AWS Cloud Map namespace ARN for Service Connect. Must be associated with the ECS cluster." +} + +variable "service_connect_port_name" { + type = string + default = null + description = "The port name to expose via Service Connect. Must exactly match the `name` field on the corresponding entry in var.port_mappings." +} + +variable "service_connect_port" { + type = number + default = null + description = "Defaults to the first containerPort in port_mappings. Override this for port remapping (e.g. expose on :80 while container listens on :8080)." +} + +# ------------------------------------------------------- +# ECS Task (optional) +# ------------------------------------------------------- + variable "container_environment" { description = "The environment variables to pass to the container" type = list(object({ From 4df10a1a70ea2ad17b3ad13cda47b6f550eb9efc Mon Sep 17 00:00:00 2001 From: mianava Date: Thu, 9 Apr 2026 11:40:54 -0400 Subject: [PATCH 2/9] Service connect enablement. --- terraform/modules/service/locals.tf | 22 +++++ terraform/modules/service/main.tf | 106 +++++++++++++++++++------ terraform/modules/service/outputs.tf | 19 +++++ terraform/modules/service/variables.tf | 61 ++++++++++---- 4 files changed, 171 insertions(+), 37 deletions(-) create mode 100644 terraform/modules/service/locals.tf diff --git a/terraform/modules/service/locals.tf b/terraform/modules/service/locals.tf new file mode 100644 index 00000000..6a8eb424 --- /dev/null +++ b/terraform/modules/service/locals.tf @@ -0,0 +1,22 @@ +locals { + service_name = coalesce(var.service_name_override, var.platform.service) + service_name_full = "${var.platform.app}-${var.platform.env}-${var.platform.service}" + + # Build a name → containerPort lookup from port_mappings + port_map = { + for pm in coalesce(var.port_mappings, []) : + pm.name => pm.containerPort + if pm.name != null && pm.containerPort != null + } + + sc_port_name = coalesce( + var.service_connect_port_name, + try([for pm in coalesce(var.port_mappings, []) : pm.name if pm.name != null][0], null) + ) + + # ALB integration is active when a listener ARN is provided + enable_alb_integration = var.alb_listener_arn != null + + # Resolve the ALB target port by name — caller must provide alb_port_name if using ALB + alb_container_port = local.enable_alb_integration ? local.port_map[var.alb_port_name] : null +} \ No newline at end of file diff --git a/terraform/modules/service/main.tf b/terraform/modules/service/main.tf index f97e8626..14d71f6c 100644 --- a/terraform/modules/service/main.tf +++ b/terraform/modules/service/main.tf @@ -1,17 +1,3 @@ -locals { - service_name = var.service_name_override != null ? var.service_name_override : var.platform.service - service_name_full = "${var.platform.app}-${var.platform.env}-${local.service_name}" - - # Derive the primary container port from port_mappings for Service Connect fallback - primary_container_port = try( - [for pm in coalesce(var.port_mappings, []) : pm.containerPort if pm.containerPort != null][0], - null - ) - - # Service Connect port: explicit override → first containerPort in port_mappings → null - sc_port = coalesce(var.service_connect_port, local.primary_container_port) -} - resource "aws_ecs_task_definition" "this" { family = local.service_name_full network_mode = "awsvpc" @@ -87,17 +73,17 @@ resource "aws_ecs_service" "this" { propagate_tags = "SERVICE" network_configuration { - subnets = var.subnets == null ? keys(var.platform.private_subnets) : var.subnets + subnets = var.subnets == null ? [for s in var.platform.private_subnets : s.id] : var.subnets assign_public_ip = false security_groups = var.security_groups } dynamic "load_balancer" { - for_each = var.load_balancers + for_each = local.enable_alb_integration ? [1] : [] content { - target_group_arn = load_balancer.value.target_group_arn - container_name = load_balancer.value.container_name - container_port = load_balancer.value.container_port + target_group_arn = aws_lb_target_group.this[0].arn + container_name = var.service_name + container_port = local.alb_container_port } } @@ -108,21 +94,94 @@ resource "aws_ecs_service" "this" { namespace = var.service_connect_namespace service { - # Must match the `name` field on the relevant entry in var.port_mappings port_name = var.service_connect_port_name client_alias { - port = local.sc_port - dns_name = local.service_name + port = local.port_map[var.service_connect_port_name] + dns_name = var.service_name } } } } - deployment_minimum_healthy_percent = 100 + deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent + deployment_maximum_percent = var.deployment_maximum_percent health_check_grace_period_seconds = var.health_check_grace_period_seconds + + depends_on = [ + aws_lb_listener_rule.this + ] + + deployment_circuit_breaker { + enable = var.deployment_circuit_breaker.enable + rollback = var.deployment_circuit_breaker.rollback + } + + lifecycle { + ignore_changes = var.ignore_desired_count_changes ? [desired_count] : [] + } +} + +# ------------------------------------------------------- +# ALB +# ------------------------------------------------------- + +resource "aws_lb_target_group" "this" { + count = local.enable_alb_integration ? 1 : 0 + + name = "${local.service_name_full}-tg" + port = local.alb_container_port + protocol = "HTTP" + vpc_id = var.platform.vpc_id + target_type = "ip" + + health_check { + path = var.alb_health_check.path + port = var.alb_health_check.port + protocol = var.alb_health_check.protocol + matcher = var.alb_health_check.matcher + interval = var.alb_health_check.interval + timeout = var.alb_health_check.timeout + healthy_threshold = var.alb_health_check.healthy_threshold + unhealthy_threshold = var.alb_health_check.unhealthy_threshold + } + + lifecycle { + precondition { + condition = var.alb_port_name != null + error_message = "alb_port_name is required when alb_listener_arn is set. Set it to the name of the port mapping in port_mappings that should receive ALB traffic." + } + + precondition { + condition = var.alb_port_name == null || contains(keys(local.port_map), var.alb_port_name) + error_message = "alb_port_name '${var.alb_port_name}' does not match any named port in port_mappings." + } + } + } +resource "aws_lb_listener_rule" "this" { + count = local.enable_alb_integration ? 1 : 0 + + listener_arn = var.alb_listener_arn + priority = var.alb_priority + + action { + type = "forward" + target_group_arn = aws_lb_target_group.this[0].arn + } + + condition { + path_pattern { + values = var.alb_path_patterns + } + } +} + +# ------------------------------------------------------- +# IAM +# ------------------------------------------------------- + data "aws_iam_policy_document" "execution" { count = var.execution_role_arn != null ? 0 : 1 statement { @@ -171,3 +230,4 @@ resource "aws_iam_role_policy" "execution" { role = aws_iam_role.execution[0].name policy = data.aws_iam_policy_document.execution[0].json } + diff --git a/terraform/modules/service/outputs.tf b/terraform/modules/service/outputs.tf index e29f04ee..c76b7078 100644 --- a/terraform/modules/service/outputs.tf +++ b/terraform/modules/service/outputs.tf @@ -3,8 +3,27 @@ output "service" { value = aws_ecs_service.this } +output "ecs_service_name" { + description = "Full name of the ECS service." + value = aws_ecs_service.this.name +} + +output "ecs_service_id" { + description = "ID of the ECS service." + value = aws_ecs_service.this.id +} + output "task_definition" { description = "The ecs task definition for the given inputs." value = aws_ecs_task_definition.this } +output "target_group_arn" { + description = "ARN of the ALB target group (if ALB integration is enabled)." + value = local.enable_alb_integration ? aws_lb_target_group.this[0].arn : null +} + +output "listener_rule_arn" { + description = "ARN of the ALB listener rule (if ALB integration is enabled)." + value = local.enable_alb_integration ? aws_lb_listener_rule.this[0].arn : null +} diff --git a/terraform/modules/service/variables.tf b/terraform/modules/service/variables.tf index 9999b624..a1833811 100644 --- a/terraform/modules/service/variables.tf +++ b/terraform/modules/service/variables.tf @@ -19,18 +19,30 @@ variable "service_connect_namespace" { description = "AWS Cloud Map namespace ARN for Service Connect. Must be associated with the ECS cluster." } -variable "service_connect_port_name" { - type = string - default = null - description = "The port name to expose via Service Connect. Must exactly match the `name` field on the corresponding entry in var.port_mappings." -} - variable "service_connect_port" { type = number default = null description = "Defaults to the first containerPort in port_mappings. Override this for port remapping (e.g. expose on :80 while container listens on :8080)." } +variable "deployment_circuit_breaker" { + type = object({ + enable = optional(bool, true) + rollback = optional(bool, false) + }) + default = {} + description = "Deployment circuit breaker configuration. Stops a failing deployment. Set rollback = true to automatically revert to the previous task definition on failure." +} + +variable "ignore_desired_count_changes" { + type = bool + default = false + description = <<-EOT + When true, Terraform will not revert desired_count to the configured value on apply. + Enable this when using Application Auto Scaling to manage task count at runtime. + EOT +} + # ------------------------------------------------------- # ECS Task (optional) # ------------------------------------------------------- @@ -93,14 +105,35 @@ variable "cpu_architecture" { default = "ARM64" } -variable "load_balancers" { - description = "Load balancer(s) for use by the AWS ECS service." - type = list(object({ - target_group_arn = string - container_name = string - container_port = number - })) - default = [] +variable "alb_port_name" { + type = string + default = null + description = "Name of the port mapping to route ALB traffic to. Must match a name in var.port_mappings. Required when alb_listener_arn is set." +} + +variable "alb_health_check" { + description = <<-EOT + Health check configuration for the ALB target group. + + path - HTTP path to probe (default: /health) + port - Port to probe. Use "traffic-port" to match the target group port + matcher - HTTP response codes considered healthy (default: "200-299") + interval - Seconds between health checks (default: 30) + timeout - Seconds before a check times out (default: 5) + healthy_threshold - Consecutive successes to mark healthy (default: 2) + unhealthy_threshold - Consecutive failures to mark unhealthy (default: 3) + EOT + type = object({ + path = optional(string, "/health") + port = optional(string, "traffic-port") + protocol = optional(string, "HTTP") + matcher = optional(string, "200-299") + interval = optional(number, 30) + timeout = optional(number, 5) + healthy_threshold = optional(number, 2) + unhealthy_threshold = optional(number, 3) + }) + default = {} } # reference: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html#task_size From 3710a03ab36977a2339c0658d20b977f82d56da6 Mon Sep 17 00:00:00 2001 From: mianava Date: Thu, 9 Apr 2026 12:10:18 -0400 Subject: [PATCH 3/9] Format. --- terraform/modules/service/locals.tf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/terraform/modules/service/locals.tf b/terraform/modules/service/locals.tf index 6a8eb424..c68894a7 100644 --- a/terraform/modules/service/locals.tf +++ b/terraform/modules/service/locals.tf @@ -19,4 +19,5 @@ locals { # Resolve the ALB target port by name — caller must provide alb_port_name if using ALB alb_container_port = local.enable_alb_integration ? local.port_map[var.alb_port_name] : null -} \ No newline at end of file +} + From b92493128569c30bb10913fa4a40668dc0f8c6ca Mon Sep 17 00:00:00 2001 From: mianava Date: Thu, 9 Apr 2026 13:53:40 -0400 Subject: [PATCH 4/9] Use CMS managed PACE for TLS --- terraform/modules/service/main.tf | 164 +++++++++++++++++- terraform/modules/service/outputs.tf | 9 +- .../services/github-actions-role/main.tf | 1 + 3 files changed, 168 insertions(+), 6 deletions(-) diff --git a/terraform/modules/service/main.tf b/terraform/modules/service/main.tf index 14d71f6c..43a05508 100644 --- a/terraform/modules/service/main.tf +++ b/terraform/modules/service/main.tf @@ -94,16 +94,25 @@ resource "aws_ecs_service" "this" { namespace = var.service_connect_namespace service { - port_name = var.service_connect_port_name + discovery_name = local.service_name + port_name = local.sc_port_name client_alias { - port = local.port_map[var.service_connect_port_name] - dns_name = var.service_name + port = local.port_map[local.sc_port_name] + dns_name = local.service_name + } + + tls { + kms_key = var.platform.kms_alias_primary.target_key_arn + role_arn = aws_iam_role.service_connect[0].arn + + issuer_cert_authority { + aws_pca_authority_arn = [one(data.aws_ram_resource_share.pace_ca.resource_arns)] + } } } } } - deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent deployment_maximum_percent = var.deployment_maximum_percent health_check_grace_period_seconds = var.health_check_grace_period_seconds @@ -146,6 +155,10 @@ resource "aws_lb_target_group" "this" { unhealthy_threshold = var.alb_health_check.unhealthy_threshold } + tags = { + Name = "${local.service_name_full}-tg" + } + lifecycle { precondition { condition = var.alb_port_name != null @@ -158,6 +171,10 @@ resource "aws_lb_target_group" "this" { } } + depends_on = [ + aws_lb_listener_rule.this, + aws_iam_role_policy_attachment.service_connect + ] } resource "aws_lb_listener_rule" "this" { @@ -231,3 +248,142 @@ resource "aws_iam_role_policy" "execution" { policy = data.aws_iam_policy_document.execution[0].json } +resource "aws_iam_role" "service_connect" { + count = var.enable_ecs_service_connect ? 1 : 0 + name = "${local.service_name_full}-service-connect" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "ecs-tasks.amazonaws.com" } + }] + }) +} + +data "aws_iam_policy_document" "service_connect" { + count = var.enable_ecs_service_connect ? 1 : 0 + + statement { + actions = [ + "acm-pca:GetCertificate", + "acm-pca:GetCertificateAuthorityCertificate", + "acm-pca:DescribeCertificateAuthority", + "acm-pca:IssueCertificate" + ] + resources = [var.platform.private_ca_arn] + } + + statement { + actions = ["kms:Decrypt", "kms:GenerateDataKey"] + resources = [var.platform.kms_alias_primary.target_key_arn] + } +} + +resource "aws_iam_role_policy" "service_connect" { + count = var.enable_ecs_service_connect ? 1 : 0 + name = "${local.service_name_full}-service-connect" + role = aws_iam_role.service_connect[0].name + policy = data.aws_iam_policy_document.service_connect[0].json +} + +data "aws_ram_resource_share" "pace_ca" { + resource_owner = "OTHER-ACCOUNTS" + name = "pace-ca-g1" +} + +data "aws_iam_policy_document" "service_connect_pca" { + statement { + sid = "AllowDescribePCA" + actions = ["acm-pca:DescribeCertificateAuthority"] + resources = [one(data.aws_ram_resource_share.pace_ca.resource_arns)] + } + + statement { + sid = "AllowGetAndIssueCertificate" + actions = ["acm-pca:GetCertificateAuthorityCsr", "acm-pca:GetCertificate", "acm-pca:IssueCertificate"] + resources = [one(data.aws_ram_resource_share.pace_ca.resource_arns)] + } +} + +resource "aws_iam_policy" "service_connect_pca" { + name = "${random_string.unique_suffix.result}-service-connect-pca-policy" + description = "Permissions for the ${var.platform.env}-${local.service_name} Service's Service Connect Role to use the PACE Private CA." + policy = data.aws_iam_policy_document.service_connect_pca.json +} + +data "aws_iam_policy_document" "service_connect_secrets_manager" { + statement { + actions = [ + "secretsmanager:CreateSecret", + "secretsmanager:TagResource", + "secretsmanager:DescribeSecret", + "secretsmanager:UpdateSecret", + "secretsmanager:GetSecretValue", + "secretsmanager:PutSecretValue", + "secretsmanager:DeleteSecret", + "secretsmanager:RotateSecret", + "secretsmanager:UpdateSecretVersionStage" + ] + resources = ["arn:aws:secretsmanager:${var.platform.primary_region.name}:${data.aws_caller_identity.current.account_id}:secret:ecs-sc!*"] + } +} + +resource "aws_iam_policy" "service_connect_secrets_manager" { + name = "${random_string.unique_suffix.result}-service-connect-secrets-manager-policy" + description = "Permissions for the ${var.platform.env} ${local.service_name} Service's Service Connect Role to use Secrets Manager for Service Connect related Secrets." + policy = data.aws_iam_policy_document.service_connect_secrets_manager.json +} + +data "aws_iam_policy_document" "service_assume_role" { + for_each = toset(["ecs-tasks", "ecs", "ECSServiceConnectForTLS"]) + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["${each.value}.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "service-connect" { + name = "${local.service_name_full}-service-connect" + assume_role_policy = data.aws_iam_policy_document.service_assume_role["ECSServiceConnectForTLS"].json + force_detach_policies = true +} + +data "aws_iam_policy_document" "kms" { + statement { + sid = "AllowEnvCMKAccess" + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey*", + "kms:ReEncrypt", + "kms:DescribeKey", + "kms:CreateGrant", + "kms:ListGrants", + "kms:RevokeGrant", + "kms:GenerateDataKeyPair", + "kms:GenerateDataKeyPairWithoutPlaintext", + ] + resources = ["*"] + } +} + +resource "aws_iam_policy" "service_connect_kms" { + name = "${random_string.unique_suffix.result}-service-connect-kms-policy" + description = "Permissions for the ${var.platform.env} ${local.service_name} Service's Service Connect Role to use the ${var.platform.env} CMK" + policy = data.aws_iam_policy_document.kms.json +} + +resource "aws_iam_role_policy_attachment" "service-connect" { + for_each = { + kms = aws_iam_policy.service_connect_kms.arn + pca = aws_iam_policy.service_connect_pca.arn + secrets_manager = aws_iam_policy.service_connect_secrets_manager.arn + } + + role = aws_iam_role.service-connect.name + policy_arn = each.value +} diff --git a/terraform/modules/service/outputs.tf b/terraform/modules/service/outputs.tf index c76b7078..5dd7ad81 100644 --- a/terraform/modules/service/outputs.tf +++ b/terraform/modules/service/outputs.tf @@ -1,5 +1,5 @@ output "service" { - description = "The ecs service for the given inputs." + description = "The ECS service resource." value = aws_ecs_service.this } @@ -14,7 +14,7 @@ output "ecs_service_id" { } output "task_definition" { - description = "The ecs task definition for the given inputs." + description = "The ECS task definition resource." value = aws_ecs_task_definition.this } @@ -27,3 +27,8 @@ output "listener_rule_arn" { description = "ARN of the ALB listener rule (if ALB integration is enabled)." value = local.enable_alb_integration ? aws_lb_listener_rule.this[0].arn : null } + +output "service_connect_role_arn" { + description = "ARN of the Service Connect IAM role (if Service Connect is enabled)." + value = var.enable_ecs_service_connect ? aws_iam_role.service_connect[0].arn : null +} diff --git a/terraform/services/github-actions-role/main.tf b/terraform/services/github-actions-role/main.tf index 3ebbe950..3f43b3a8 100644 --- a/terraform/services/github-actions-role/main.tf +++ b/terraform/services/github-actions-role/main.tf @@ -225,6 +225,7 @@ data "aws_iam_policy_document" "github_actions_policy" { "ecs:DescribeServices", "ecs:DescribeTaskDefinition", "ecs:DescribeTasks", + "ecs:ListClusters", "ecs:ListTaskDefinitions", "ecs:ListTasks", "ecs:RegisterTaskDefinition", From 911ad81c18aa85f725df26abdd4967098252b71e Mon Sep 17 00:00:00 2001 From: mianava Date: Thu, 9 Apr 2026 13:55:43 -0400 Subject: [PATCH 5/9] Reflect Service Connect enablement. --- terraform/modules/service/README.md | 33 ++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/terraform/modules/service/README.md b/terraform/modules/service/README.md index de190bc6..601b1984 100644 --- a/terraform/modules/service/README.md +++ b/terraform/modules/service/README.md @@ -113,17 +113,24 @@ No requirements. | [memory](#input\_memory) | Amount (in MiB) of memory used by the task. | `number` | n/a | yes | | [platform](#input\_platform) | Object representing the CDAP plaform module. |
object({
app = string
env = string
kms_alias_primary = object({ target_key_arn = string })
primary_region = object({ name = string })
private_subnets = map(object({ id = string }))
service = string
})
| n/a | yes | | [task\_role\_arn](#input\_task\_role\_arn) | ARN of the role that allows the application code in tasks to make calls to AWS services. | `string` | n/a | yes | +| [alb\_health\_check](#input\_alb\_health\_check) | Health check configuration for the ALB target group.

path - HTTP path to probe (default: /health)
port - Port to probe. Use "traffic-port" to match the target group port
matcher - HTTP response codes considered healthy (default: "200-299")
interval - Seconds between health checks (default: 30)
timeout - Seconds before a check times out (default: 5)
healthy\_threshold - Consecutive successes to mark healthy (default: 2)
unhealthy\_threshold - Consecutive failures to mark unhealthy (default: 3) |
object({
path = optional(string, "/health")
port = optional(string, "traffic-port")
protocol = optional(string, "HTTP")
matcher = optional(string, "200-299")
interval = optional(number, 30)
timeout = optional(number, 5)
healthy_threshold = optional(number, 2)
unhealthy_threshold = optional(number, 3)
})
| `{}` | no | +| [alb\_port\_name](#input\_alb\_port\_name) | Name of the port mapping to route ALB traffic to. Must match a name in var.port\_mappings. Required when alb\_listener\_arn is set. | `string` | `null` | no | | [container\_environment](#input\_container\_environment) | The environment variables to pass to the container |
list(object({
name = string
value = string
}))
| `null` | no | | [container\_secrets](#input\_container\_secrets) | The secrets to pass to the container. For more information, see [Specifying Sensitive Data](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/specifying-sensitive-data.html) in the Amazon Elastic Container Service Developer Guide |
list(object({
name = string
valueFrom = string
}))
| `null` | no | +| [cpu\_architecture](#input\_cpu\_architecture) | The cpu architecture needed. | `string` | `"ARM64"` | no | +| [deployment\_circuit\_breaker](#input\_deployment\_circuit\_breaker) | Deployment circuit breaker configuration. Stops a failing deployment. Set rollback = true to automatically revert to the previous task definition on failure. |
object({
enable = optional(bool, true)
rollback = optional(bool, false)
})
| `{}` | no | | [desired\_count](#input\_desired\_count) | Number of instances of the task definition to place and keep running. | `number` | `1` | no | +| [enable\_ecs\_service\_connect](#input\_enable\_ecs\_service\_connect) | Enables ECS Service Connect so other services in the namespace can reach this one. | `bool` | `false` | no | | [execution\_role\_arn](#input\_execution\_role\_arn) | ARN of the role that grants Fargate agents permission to make AWS API calls to pull images for containers, get SSM params in the task definition, etc. Defaults to creation of a new role. | `string` | `null` | no | | [force\_new\_deployment](#input\_force\_new\_deployment) | When *changed* to `true`, trigger a new deployment of the ECS Service even when a deployment wouldn't otherwise be triggered by other changes. **Note**: This has no effect when the value is `false`, changed to `false`, or set to `true` between consecutive applies. | `bool` | `false` | no | | [health\_check](#input\_health\_check) | Health check that monitors the service. |
object({
command = list(string),
interval = optional(number),
retries = optional(number),
startPeriod = optional(number),
timeout = optional(number)
})
| `null` | no | | [health\_check\_grace\_period\_seconds](#input\_health\_check\_grace\_period\_seconds) | Seconds to ignore failing load balancer health checks on newly instantiated tasks to prevent premature shutdown, up to 2147483647. Only valid for services configured to use load balancers. | `number` | `null` | no | -| [load\_balancers](#input\_load\_balancers) | Load balancer(s) for use by the AWS ECS service. |
list(object({
target_group_arn = string
container_name = string
container_port = number
}))
| `[]` | no | +| [ignore\_desired\_count\_changes](#input\_ignore\_desired\_count\_changes) | When true, Terraform will not revert desired\_count to the configured value on apply.
Enable this when using Application Auto Scaling to manage task count at runtime. | `bool` | `false` | no | | [mount\_points](#input\_mount\_points) | The mount points for data volumes in your container |
list(object({
containerPath = optional(string)
readOnly = optional(bool)
sourceVolume = optional(string)
}))
| `null` | no | | [port\_mappings](#input\_port\_mappings) | The list of port mappings for the container. Port mappings allow containers to access ports on the host container instance to send or receive traffic. For task definitions that use the awsvpc network mode, only specify the containerPort. The hostPort can be left blank or it must be the same value as the containerPort |
list(object({
appProtocol = optional(string)
containerPort = optional(number)
containerPortRange = optional(string)
hostPort = optional(number)
name = optional(string)
protocol = optional(string)
}))
| `null` | no | | [security\_groups](#input\_security\_groups) | List of security groups to associate with the service. | `list(string)` | `[]` | no | +| [service\_connect\_namespace](#input\_service\_connect\_namespace) | AWS Cloud Map namespace ARN for Service Connect. Must be associated with the ECS cluster. | `string` | `null` | no | +| [service\_connect\_port](#input\_service\_connect\_port) | Defaults to the first containerPort in port\_mappings. Override this for port remapping (e.g. expose on :80 while container listens on :8080). | `number` | `null` | no | | [service\_name\_override](#input\_service\_name\_override) | Desired service name for the service tag on the aws ecs service. Defaults to var.platform.app-var.platform.env-var.platform.service. | `string` | `null` | no | | [subnets](#input\_subnets) | Optional list of subnets associated with the service. Defaults to private subnets as specified by the platform module. | `list(string)` | `null` | no | | [volumes](#input\_volumes) | Configuration block for volumes that containers in your task may use |
list(object({
configure_at_launch = optional(bool)
efs_volume_configuration = optional(object({
authorization_config = optional(object({
access_point_id = optional(string)
iam = optional(string)
}))
file_system_id = string
root_directory = optional(string)
}))
host_path = optional(string)
name = string
}))
| `null` | no | @@ -148,9 +155,24 @@ No modules. |------|------| | [aws_ecs_service.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_service) | resource | | [aws_ecs_task_definition.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition) | resource | +| [aws_iam_policy.service_connect_kms](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_iam_policy.service_connect_pca](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | +| [aws_iam_policy.service_connect_secrets_manager](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_iam_role.execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.service-connect](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | +| [aws_iam_role.service_connect](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy.execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy.service_connect](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource | +| [aws_iam_role_policy_attachment.service-connect](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | +| [aws_lb_listener_rule.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_listener_rule) | resource | +| [aws_lb_target_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_target_group) | resource | | [aws_iam_policy_document.execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.kms](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.service_assume_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.service_connect](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.service_connect_pca](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_iam_policy_document.service_connect_secrets_manager](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | +| [aws_ram_resource_share.pace_ca](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ram_resource_share) | data source | From ece1b11a939fc514d13af8f417f34bf81d00ca35 Mon Sep 17 00:00:00 2001 From: mianava Date: Fri, 10 Apr 2026 10:44:23 -0400 Subject: [PATCH 6/9] Refine IAM --- terraform/modules/service/iam.tf | 134 +++++++++++++++++ terraform/modules/service/main.tf | 198 +------------------------ terraform/modules/service/variables.tf | 6 +- 3 files changed, 142 insertions(+), 196 deletions(-) create mode 100644 terraform/modules/service/iam.tf diff --git a/terraform/modules/service/iam.tf b/terraform/modules/service/iam.tf new file mode 100644 index 00000000..fa294149 --- /dev/null +++ b/terraform/modules/service/iam.tf @@ -0,0 +1,134 @@ +# -------------------------------- +# Task Role IAM handled externally +#--------------------------------- + +# -------------------- +# Execution Role IAM +#---------------------- + +resource "aws_iam_role" "execution" { + count = var.execution_role_arn != null ? 0 : 1 + name = "${local.service_name_full}-execution" + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + }, + ] + }) +} + +resource "aws_iam_role_policy" "execution" { + count = var.execution_role_arn != null ? 0 : 1 + name = "${aws_ecs_task_definition.this.family}-execution" + role = aws_iam_role.execution[0].name + policy = data.aws_iam_policy_document.execution[0].json +} + +data "aws_iam_policy_document" "execution" { + count = var.execution_role_arn != null ? 0 : 1 + statement { + actions = [ + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage", + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "ssm:GetParameters" + ] + resources = ["*"] + } + + statement { + actions = [ + "kms:Decrypt" + ] + resources = [var.platform.kms_alias_primary.target_key_arn] + effect = "Allow" + } +} + +# ------------------------- +# Service Connect Role IAM +#--------------------------- + +resource "aws_iam_role_policy_attachment" "service-connect" { + role = aws_iam_role.service_connect.name + policy_arn = aws_iam_role_policy.service_connect.arn +} + +resource "aws_iam_role" "service_connect" { + count = var.enable_ecs_service_connect ? 1 : 0 + name = "${local.service_name_full}-service-connect" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "ecs-tasks.amazonaws.com" } + }] + }) +} + +# encrypted certificate lifecycle and storage +resource "aws_iam_role_policy" "service_connect" { + count = var.enable_ecs_service_connect ? 1 : 0 + name = "${local.service_name_full}-service-connect" + role = aws_iam_role.service_connect[0].name + policy = data.aws_iam_policy_document.service_connect.json +} + +data "aws_iam_policy_document" "service_connect" { + statement { + sid = "AllowPCAUse" + actions = [ + "acm-pca:GetCertificate", + "acm-pca:GetCertificateAuthorityCertificate", + "acm-pca:DescribeCertificateAuthority", + "acm-pca:IssueCertificate" + ] + resources = [data.aws_ram_resource_share.pace_ca.resource_arns] + } + + statement { + sid = "AllowCertManagement" + actions = [ + "acm:ExportCertificate", + "acm:DescribeCertificate", + "acm:GetCertificate" + ] + resources = ["arn:aws:acm:${var.platform.primary_region.name}:${var.platform.account_id}:certificate/*"] + } + + statement { + actions = [ + "secretsmanager:CreateSecret", + "secretsmanager:TagResource", + "secretsmanager:DescribeSecret", + "secretsmanager:UpdateSecret", + "secretsmanager:GetSecretValue", + "secretsmanager:PutSecretValue", + "secretsmanager:DeleteSecret", + "secretsmanager:RotateSecret", + "secretsmanager:UpdateSecretVersionStage" + ] + resources = ["arn:aws:secretsmanager:${var.platform.primary_region.name}:${data.aws_caller_identity.current.account_id}:secret:ecs-sc!*"] + } + + statement { + sid = "AllowKMSDecrypt" + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey" + ] + resources = [var.platform.kms_alias_primary.target_key_arn] + } +} diff --git a/terraform/modules/service/main.tf b/terraform/modules/service/main.tf index 43a05508..4c9b0aa9 100644 --- a/terraform/modules/service/main.tf +++ b/terraform/modules/service/main.tf @@ -1,3 +1,8 @@ +data "aws_ram_resource_share" "pace_ca" { + resource_owner = "OTHER-ACCOUNTS" + name = "pace-ca-g1" +} + resource "aws_ecs_task_definition" "this" { family = local.service_name_full network_mode = "awsvpc" @@ -194,196 +199,3 @@ resource "aws_lb_listener_rule" "this" { } } } - -# ------------------------------------------------------- -# IAM -# ------------------------------------------------------- - -data "aws_iam_policy_document" "execution" { - count = var.execution_role_arn != null ? 0 : 1 - statement { - actions = [ - "ecr:GetAuthorizationToken", - "ecr:BatchCheckLayerAvailability", - "ecr:GetDownloadUrlForLayer", - "ecr:BatchGetImage", - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents", - "ssm:GetParameters" - ] - resources = ["*"] - } - - statement { - actions = [ - "kms:Decrypt" - ] - resources = [var.platform.kms_alias_primary.target_key_arn] - effect = "Allow" - } -} - -resource "aws_iam_role" "execution" { - count = var.execution_role_arn != null ? 0 : 1 - name = "${local.service_name_full}-execution" - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "ecs-tasks.amazonaws.com" - } - }, - ] - }) -} - -resource "aws_iam_role_policy" "execution" { - count = var.execution_role_arn != null ? 0 : 1 - name = "${aws_ecs_task_definition.this.family}-execution" - role = aws_iam_role.execution[0].name - policy = data.aws_iam_policy_document.execution[0].json -} - -resource "aws_iam_role" "service_connect" { - count = var.enable_ecs_service_connect ? 1 : 0 - name = "${local.service_name_full}-service-connect" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { Service = "ecs-tasks.amazonaws.com" } - }] - }) -} - -data "aws_iam_policy_document" "service_connect" { - count = var.enable_ecs_service_connect ? 1 : 0 - - statement { - actions = [ - "acm-pca:GetCertificate", - "acm-pca:GetCertificateAuthorityCertificate", - "acm-pca:DescribeCertificateAuthority", - "acm-pca:IssueCertificate" - ] - resources = [var.platform.private_ca_arn] - } - - statement { - actions = ["kms:Decrypt", "kms:GenerateDataKey"] - resources = [var.platform.kms_alias_primary.target_key_arn] - } -} - -resource "aws_iam_role_policy" "service_connect" { - count = var.enable_ecs_service_connect ? 1 : 0 - name = "${local.service_name_full}-service-connect" - role = aws_iam_role.service_connect[0].name - policy = data.aws_iam_policy_document.service_connect[0].json -} - -data "aws_ram_resource_share" "pace_ca" { - resource_owner = "OTHER-ACCOUNTS" - name = "pace-ca-g1" -} - -data "aws_iam_policy_document" "service_connect_pca" { - statement { - sid = "AllowDescribePCA" - actions = ["acm-pca:DescribeCertificateAuthority"] - resources = [one(data.aws_ram_resource_share.pace_ca.resource_arns)] - } - - statement { - sid = "AllowGetAndIssueCertificate" - actions = ["acm-pca:GetCertificateAuthorityCsr", "acm-pca:GetCertificate", "acm-pca:IssueCertificate"] - resources = [one(data.aws_ram_resource_share.pace_ca.resource_arns)] - } -} - -resource "aws_iam_policy" "service_connect_pca" { - name = "${random_string.unique_suffix.result}-service-connect-pca-policy" - description = "Permissions for the ${var.platform.env}-${local.service_name} Service's Service Connect Role to use the PACE Private CA." - policy = data.aws_iam_policy_document.service_connect_pca.json -} - -data "aws_iam_policy_document" "service_connect_secrets_manager" { - statement { - actions = [ - "secretsmanager:CreateSecret", - "secretsmanager:TagResource", - "secretsmanager:DescribeSecret", - "secretsmanager:UpdateSecret", - "secretsmanager:GetSecretValue", - "secretsmanager:PutSecretValue", - "secretsmanager:DeleteSecret", - "secretsmanager:RotateSecret", - "secretsmanager:UpdateSecretVersionStage" - ] - resources = ["arn:aws:secretsmanager:${var.platform.primary_region.name}:${data.aws_caller_identity.current.account_id}:secret:ecs-sc!*"] - } -} - -resource "aws_iam_policy" "service_connect_secrets_manager" { - name = "${random_string.unique_suffix.result}-service-connect-secrets-manager-policy" - description = "Permissions for the ${var.platform.env} ${local.service_name} Service's Service Connect Role to use Secrets Manager for Service Connect related Secrets." - policy = data.aws_iam_policy_document.service_connect_secrets_manager.json -} - -data "aws_iam_policy_document" "service_assume_role" { - for_each = toset(["ecs-tasks", "ecs", "ECSServiceConnectForTLS"]) - statement { - actions = ["sts:AssumeRole"] - principals { - type = "Service" - identifiers = ["${each.value}.amazonaws.com"] - } - } -} - -resource "aws_iam_role" "service-connect" { - name = "${local.service_name_full}-service-connect" - assume_role_policy = data.aws_iam_policy_document.service_assume_role["ECSServiceConnectForTLS"].json - force_detach_policies = true -} - -data "aws_iam_policy_document" "kms" { - statement { - sid = "AllowEnvCMKAccess" - actions = [ - "kms:Decrypt", - "kms:GenerateDataKey*", - "kms:ReEncrypt", - "kms:DescribeKey", - "kms:CreateGrant", - "kms:ListGrants", - "kms:RevokeGrant", - "kms:GenerateDataKeyPair", - "kms:GenerateDataKeyPairWithoutPlaintext", - ] - resources = ["*"] - } -} - -resource "aws_iam_policy" "service_connect_kms" { - name = "${random_string.unique_suffix.result}-service-connect-kms-policy" - description = "Permissions for the ${var.platform.env} ${local.service_name} Service's Service Connect Role to use the ${var.platform.env} CMK" - policy = data.aws_iam_policy_document.kms.json -} - -resource "aws_iam_role_policy_attachment" "service-connect" { - for_each = { - kms = aws_iam_policy.service_connect_kms.arn - pca = aws_iam_policy.service_connect_pca.arn - secrets_manager = aws_iam_policy.service_connect_secrets_manager.arn - } - - role = aws_iam_role.service-connect.name - policy_arn = each.value -} diff --git a/terraform/modules/service/variables.tf b/terraform/modules/service/variables.tf index a1833811..72fb2482 100644 --- a/terraform/modules/service/variables.tf +++ b/terraform/modules/service/variables.tf @@ -12,7 +12,7 @@ variable "enable_ecs_service_connect" { default = false } -# TODO: Evaluate whether this should be set at the cluster/stack level and passed down +# Define where this gets set by developers variable "service_connect_namespace" { type = string default = null @@ -83,7 +83,7 @@ variable "desired_count" { } variable "execution_role_arn" { - description = "ARN of the role that grants Fargate agents permission to make AWS API calls to pull images for containers, get SSM params in the task definition, etc. Defaults to creation of a new role." + description = "Deprecated. Do not set. ARN of the role that grants Fargate agents permission to make AWS API calls to pull images for containers, get SSM params in the task definition, etc. Defaults to creation of a new role." type = string default = null } @@ -208,7 +208,7 @@ variable "subnets" { } variable "task_role_arn" { - description = "ARN of the role that allows the application code in tasks to make calls to AWS services." + description = "Distinct from execution role. ARN of the role that allows the application code in tasks to make calls to AWS services." type = string } From 50ff298ef5792712dd2f8a7b2a1b8c7bf0fd323f Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 21 Apr 2026 00:54:17 -0400 Subject: [PATCH 7/9] Add test module to evaluate service module --- terraform/modules/service/variables.tf | 6 + terraform/services/tftesting/service/iam.tf | 83 ++++++++++++ terraform/services/tftesting/service/main.tf | 125 ++++++++++++++++++ .../services/tftesting/service/outputs.tf | 17 +++ .../services/tftesting/service/terraform.tf | 0 terraform/services/tftesting/service/tofu.tf | 26 ++++ 6 files changed, 257 insertions(+) create mode 100644 terraform/services/tftesting/service/iam.tf create mode 100644 terraform/services/tftesting/service/main.tf create mode 100644 terraform/services/tftesting/service/outputs.tf create mode 100644 terraform/services/tftesting/service/terraform.tf create mode 100644 terraform/services/tftesting/service/tofu.tf diff --git a/terraform/modules/service/variables.tf b/terraform/modules/service/variables.tf index 72fb2482..ba794bfd 100644 --- a/terraform/modules/service/variables.tf +++ b/terraform/modules/service/variables.tf @@ -25,6 +25,12 @@ variable "service_connect_port" { description = "Defaults to the first containerPort in port_mappings. Override this for port remapping (e.g. expose on :80 while container listens on :8080)." } +variable "service_connect_port_name" { + type = string + default = null + description = "Name of the port mapping to use for Service Connect. Defaults to the first named port in port_mappings." +} + variable "deployment_circuit_breaker" { type = object({ enable = optional(bool, true) diff --git a/terraform/services/tftesting/service/iam.tf b/terraform/services/tftesting/service/iam.tf new file mode 100644 index 00000000..584aa8d7 --- /dev/null +++ b/terraform/services/tftesting/service/iam.tf @@ -0,0 +1,83 @@ +# ------------------------------------------------------- +# Task Role — assumed by the running container +# ------------------------------------------------------- +resource "aws_iam_role" "task" { + name = "cdap-test-tftesting-task-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = "sts:AssumeRole" + Principal = { Service = "ecs-tasks.amazonaws.com" } + } + ] + }) + + tags = { + Name = "cdap-test-tftesting-task-role" + } +} + +resource "aws_iam_role_policy" "task" { + name = "cdap-test-tftesting-task-policy" + role = aws_iam_role.task.name + policy = data.aws_iam_policy_document.task.json +} + +data "aws_iam_policy_document" "task" { + statement { + sid = "AllowKMSDecrypt" + actions = [ + "kms:Decrypt", + "kms:GenerateDataKey" + ] + resources = [module.platform.kms_alias_primary.target_key_arn] + } + + statement { + sid = "AllowSSMRead" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath" + ] + resources = [ + "arn:aws:ssm:${module.platform.primary_region.name}:${module.platform.aws_caller_identity}:/cdap/test/tftesting/*" + ] + } + + statement { + sid = "AllowACMRead" + actions = [ + "acm:ExportCertificate", + "acm:DescribeCertificate", + "acm:GetCertificate" + ] + resources = [ + "arn:aws:acm:${module.platform.primary_region.name}:${module.platform.aws_caller_identity}:certificate/*" + ] + } + + statement { + sid = "AllowECRAuthToken" + actions = [ + "ecr:GetAuthorizationToken" + ] + resources = ["*"] + } + + statement { + sid = "AllowCloudWatchLogs" + actions = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + resources = [ + "arn:aws:logs:${module.platform.primary_region.name}:${module.platform.aws_caller_identity}:log-group:/aws/ecs/fargate/cdap-test/*" + ] + } +} + diff --git a/terraform/services/tftesting/service/main.tf b/terraform/services/tftesting/service/main.tf new file mode 100644 index 00000000..bdd0d245 --- /dev/null +++ b/terraform/services/tftesting/service/main.tf @@ -0,0 +1,125 @@ +locals { + default_tags = module.platform.default_tags +} + +module "platform" { + source = "../../../modules/platform" + providers = { aws = aws, aws.secondary = aws.secondary } + + app = "cdap" + env = "test" + root_module = "https://github.com/CMSgov/cdap/tree/main/terraform/services/tftesting/service" + service = "tftesting" +} + +# ------------------------------------------------------- +# Cluster +# ------------------------------------------------------- +resource "aws_ecs_cluster" "test" { + name = "cdap-test-tftesting-cluster" + + # Service Connect requires a default namespace on the cluster + service_connect_defaults { + namespace = aws_service_discovery_http_namespace.test.arn + } +} + +# ------------------------------------------------------- +# Cloud Map Namespace (required for Service Connect) +# ------------------------------------------------------- +resource "aws_service_discovery_http_namespace" "test" { + name = "cdap-test-tftesting" + description = "Service Connect namespace for ECS module testing" +} + +# ------------------------------------------------------- +# Security Group — sourcing vpc_id from platform module +# ------------------------------------------------------- +resource "aws_security_group" "ecs_task" { + name = "cdap-test-tftesting-ecs-module-sg" + description = "Allow inbound HTTP for ECS task test" + vpc_id = module.platform.vpc_id # <-- from platform module + + ingress { + description = "HTTP inbound" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + cidr_blocks = [module.platform.vpc_cidr] # or hardcode if platform doesn't expose cidr + } + + egress { + description = "Allow all outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +# ------------------------------------------------------- +# ECS Service Module +# ------------------------------------------------------- +module "service" { + source = "../../../modules/service/" + + # ------------------------------------------------------- + # Platform — passed through from your platform module + # ------------------------------------------------------- + platform = { + app = module.platform.app + env = module.platform.env + service = "tftesting" + + kms_alias_primary = { + target_key_arn = module.platform.kms_alias_primary.target_key_arn + } + + primary_region = { + name = module.platform.primary_region.name + } + + private_subnets = module.platform.private_subnets + vpc_id = module.platform.vpc_id + } + + cluster_arn = aws_ecs_cluster.test.arn + + image = "public.ecr.aws/nginx/nginx:latest" + cpu = 256 + memory = 512 + + port_mappings = [ + { + name = "http" + containerPort = 8080 + protocol = "tcp" + appProtocol = "http" + } + ] + service_connect_port_name = "http" + health_check = { + command = ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"] + interval = 30 + retries = 3 + startPeriod = 15 + timeout = 5 + } + + task_role_arn = aws_iam_role.task.arn + security_groups = [aws_security_group.ecs_task.id] + desired_count = 1 + force_new_deployment = false + container_environment = [] + container_secrets = [] + + # ------------------------------------------------------- + # Service Connect Testing + # ------------------------------------------------------- + enable_ecs_service_connect = true + service_connect_namespace = aws_service_discovery_http_namespace.test.arn + deployment_circuit_breaker = { + enable = true + rollback = true + } +} diff --git a/terraform/services/tftesting/service/outputs.tf b/terraform/services/tftesting/service/outputs.tf new file mode 100644 index 00000000..10ce44c0 --- /dev/null +++ b/terraform/services/tftesting/service/outputs.tf @@ -0,0 +1,17 @@ +output "cluster_arn" { + description = "ARN of the test ECS cluster" + value = aws_ecs_cluster.test.arn +} + +output "service_name" { + description = "Name of the ECS service created by the module" + value = module.ecs_service.service_name # adjust to your module's output name +} + +output "task_role_arn" { + value = aws_iam_role.task.arn +} + +output "execution_role_arn" { + value = aws_iam_role.execution.arn +} diff --git a/terraform/services/tftesting/service/terraform.tf b/terraform/services/tftesting/service/terraform.tf new file mode 100644 index 00000000..e69de29b diff --git a/terraform/services/tftesting/service/tofu.tf b/terraform/services/tftesting/service/tofu.tf new file mode 100644 index 00000000..4dedb04e --- /dev/null +++ b/terraform/services/tftesting/service/tofu.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} + +provider "aws" { + region = "us-east-1" + default_tags { + tags = local.default_tags + } +} + +provider "aws" { + alias = "secondary" + + region = "us-west-2" + default_tags { + tags = local.default_tags + } +} \ No newline at end of file From bcad7f3448a7c975a032db96c87681d84f6b4c75 Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 21 Apr 2026 02:05:54 -0400 Subject: [PATCH 8/9] Configure a test file to evaluate ecs services with extendability to test with alb and acm --- .../tftesting/{service => ecs-stack}/iam.tf | 30 +- .../services/tftesting/ecs-stack/main.tf | 331 ++++++++++++++++++ .../services/tftesting/ecs-stack/outputs.tf | 8 + .../services/tftesting/ecs-stack/tofu.tf | 20 ++ terraform/services/tftesting/service/main.tf | 125 ------- .../services/tftesting/service/outputs.tf | 17 - .../services/tftesting/service/terraform.tf | 0 terraform/services/tftesting/service/tofu.tf | 26 -- 8 files changed, 375 insertions(+), 182 deletions(-) rename terraform/services/tftesting/{service => ecs-stack}/iam.tf (72%) create mode 100644 terraform/services/tftesting/ecs-stack/main.tf create mode 100644 terraform/services/tftesting/ecs-stack/outputs.tf create mode 100644 terraform/services/tftesting/ecs-stack/tofu.tf delete mode 100644 terraform/services/tftesting/service/main.tf delete mode 100644 terraform/services/tftesting/service/outputs.tf delete mode 100644 terraform/services/tftesting/service/terraform.tf delete mode 100644 terraform/services/tftesting/service/tofu.tf diff --git a/terraform/services/tftesting/service/iam.tf b/terraform/services/tftesting/ecs-stack/iam.tf similarity index 72% rename from terraform/services/tftesting/service/iam.tf rename to terraform/services/tftesting/ecs-stack/iam.tf index 584aa8d7..f237f127 100644 --- a/terraform/services/tftesting/service/iam.tf +++ b/terraform/services/tftesting/ecs-stack/iam.tf @@ -44,21 +44,24 @@ data "aws_iam_policy_document" "task" { "ssm:GetParametersByPath" ] resources = [ - "arn:aws:ssm:${module.platform.primary_region.name}:${module.platform.aws_caller_identity}:/cdap/test/tftesting/*" + "arn:aws:ssm:${module.platform.primary_region.name}:${module.platform.aws_caller_identity.account_id}:/cdap/test/tftesting/*" ] } - statement { - sid = "AllowACMRead" - actions = [ - "acm:ExportCertificate", - "acm:DescribeCertificate", - "acm:GetCertificate" - ] - resources = [ - "arn:aws:acm:${module.platform.primary_region.name}:${module.platform.aws_caller_identity}:certificate/*" - ] - } + # # ------------------------------------------------------- + # # ACM Certificate test with private cert + # # ------------------------------------------------------- + # statement { + # sid = "AllowACMRead" + # actions = [ + # "acm:ExportCertificate", + # "acm:DescribeCertificate", + # "acm:GetCertificate" + # ] + # resources = [ + # module.acm_certificate.private_cert_arn + # ] + # } statement { sid = "AllowECRAuthToken" @@ -76,8 +79,7 @@ data "aws_iam_policy_document" "task" { "logs:PutLogEvents" ] resources = [ - "arn:aws:logs:${module.platform.primary_region.name}:${module.platform.aws_caller_identity}:log-group:/aws/ecs/fargate/cdap-test/*" + "arn:aws:logs:${module.platform.primary_region.name}:${module.platform.aws_caller_identity.account_id}:log-group:/aws/ecs/fargate/cdap-test/*" ] } } - diff --git a/terraform/services/tftesting/ecs-stack/main.tf b/terraform/services/tftesting/ecs-stack/main.tf new file mode 100644 index 00000000..50be8d73 --- /dev/null +++ b/terraform/services/tftesting/ecs-stack/main.tf @@ -0,0 +1,331 @@ +locals { + default_tags = module.platform.default_tags +} + +module "platform" { + source = "../../../modules/platform" + providers = { aws = aws, aws.secondary = aws.secondary } + + app = "cdap" + env = "test" + root_module = "https://github.com/CMSgov/cdap/tree/main/terraform/services/tftesting/service" + service = "tftesting" +} + +# # ------------------------------------------------------- +# # ALB test with internal, using private cert +# # ------------------------------------------------------- +# module "alb" { +# source = "../../../modules/alb" +# +# platform = { +# app = module.platform.app +# env = module.platform.env +# service = "tftesting" +# +# primary_region = { name = module.platform.primary_region.name } +# private_subnets = module.platform.private_subnets +# vpc_id = module.platform.vpc_id +# } +# +# internal = true +# acm_certificate_arn = module.acm_certificate.private_cert_arn +# security_group_ids = [aws_security_group.alb.id] +# +# enable_http_redirect = true +# } +# +# resource "aws_security_group" "alb" { +# name = "cdap-test-tftesting-alb-sg" +# description = "Allow HTTPS inbound to ALB" +# vpc_id = module.platform.vpc_id +# +# ingress { +# description = "HTTPS from VPC" +# from_port = 443 +# to_port = 443 +# protocol = "tcp" +# cidr_blocks = [module.platform.platform_cidr] +# } +# +# ingress { +# description = "HTTP from VPC (redirect to HTTPS)" +# from_port = 80 +# to_port = 80 +# protocol = "tcp" +# cidr_blocks = [module.platform.platform_cidr] +# } +# +# egress { +# description = "Allow all outbound" +# from_port = 0 +# to_port = 0 +# protocol = "-1" +# cidr_blocks = ["0.0.0.0/0"] +# } +# } +# +# resource "aws_security_group_rule" "ecs_from_alb" { +# type = "ingress" +# from_port = 8080 +# to_port = 8080 +# protocol = "tcp" +# security_group_id = aws_security_group.ecs_task.id +# source_security_group_id = aws_security_group.alb.id +# description = "Allow inbound from ALB" +# } +# # ------------------------------------------------------- +# # ACM Certificate test with private cert +# # ------------------------------------------------------- +# +# module "acm_certificate" { +# source = "../../../modules/acm_certificate" +# +# platform = { +# app = module.platform.app +# env = module.platform.env +# service = "tftesting" +# +# kms_alias_primary = { +# target_key_arn = module.platform.kms_alias_primary.target_key_arn +# } +# +# primary_region = { +# name = module.platform.primary_region.name +# } +# +# private_subnets = module.platform.private_subnets +# vpc_id = module.platform.vpc_id +# } +# +# # Testing private cert only +# enable_internal_endpoint = true +# enable_zscaler_endpoint = false +# public_domain_name = null +# } + + +# ------------------------------------------------------- +# Cluster +# ------------------------------------------------------- +resource "aws_ecs_cluster" "test" { + name = "cdap-test-tftesting-cluster" + + # Service Connect requires a default namespace on the cluster + service_connect_defaults { + namespace = aws_service_discovery_http_namespace.test.arn + } +} + +# ------------------------------------------------------- +# Cloud Map Namespace (required for Service Connect) +# ------------------------------------------------------- +resource "aws_service_discovery_http_namespace" "test" { + name = "cdap-test-tftesting" + description = "Service Connect namespace for ECS module testing" +} + +# ------------------------------------------------------- +# Security Group — sourcing vpc_id from platform module +# ------------------------------------------------------- +resource "aws_security_group" "ecs_task" { + name = "cdap-test-tftesting-ecs-module-sg" + description = "Allow inbound HTTP for ECS task test" + vpc_id = module.platform.vpc_id # + + ingress { + description = "HTTP inbound" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + cidr_blocks = [module.platform.platform_cidr] + } + + egress { + description = "Allow all outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +# ------------------------------------------------------- +# ECS Service Module +# ------------------------------------------------------- +module "service" { + source = "../../../modules/service/" + + # ------------------------------------------------------- + # Platform — passed through from your platform module + # ------------------------------------------------------- + platform = { + app = module.platform.app + env = module.platform.env + service = "tftesting" + + kms_alias_primary = { + target_key_arn = module.platform.kms_alias_primary.target_key_arn + } + + primary_region = { + name = module.platform.primary_region.name + } + + private_subnets = module.platform.private_subnets + vpc_id = module.platform.vpc_id + account_id = module.platform.aws_caller_identity.account_id + } + + cluster_arn = aws_ecs_cluster.test.arn + + image = "public.ecr.aws/nginx/nginx:latest" + cpu = 256 + memory = 512 + + port_mappings = [ + { + name = "http" + containerPort = 8080 + protocol = "tcp" + appProtocol = "http" + } + ] + service_connect_port_name = "http" + health_check = { + command = ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"] + interval = 30 + retries = 3 + startPeriod = 15 + timeout = 5 + } + + task_role_arn = aws_iam_role.task.arn + security_groups = [aws_security_group.ecs_task.id] + desired_count = 1 + force_new_deployment = false + container_environment = [] + container_secrets = [] + + # ------------------------------------------------------- + # ALB Testing + # ------------------------------------------------------- + + # alb_listener_arn = module.alb.https_listener_arn + # alb_port_name = "http" + # alb_path_patterns = ["/*"] + # alb_priority = 100 + # ------------------------------------------------------- + # Service Connect Testing + # ------------------------------------------------------- + enable_ecs_service_connect = true + service_connect_namespace = aws_service_discovery_http_namespace.test.arn + deployment_circuit_breaker = { + enable = true + rollback = true + } +} + + + +# ------------------------------------------------------- +# Old-style target group — created externally by the caller +# (this is what existing callers like ab2d manage themselves) +# ------------------------------------------------------- +resource "aws_lb_target_group" "legacy_test" { + name = "cdap-test-tftesting-legacy-tg" + port = 8081 + protocol = "HTTP" + vpc_id = module.platform.vpc_id + target_type = "ip" + + health_check { + path = "/" + port = "traffic-port" + protocol = "HTTP" + matcher = "200-299" + interval = 30 + timeout = 5 + healthy_threshold = 2 + unhealthy_threshold = 3 + } + + tags = { + Name = "cdap-test-tftesting-legacy-tg" + } +} + +# ------------------------------------------------------- +# Backwards compatibility test — uses load_balancers, not alb_listener_arn +# ------------------------------------------------------- +module "service_legacy" { + source = "../../../modules/service/" + + platform = { + app = module.platform.app + env = module.platform.env + service = "tftesting-legacy" + + kms_alias_primary = { + target_key_arn = module.platform.kms_alias_primary.target_key_arn + } + + primary_region = { name = module.platform.primary_region.name } + private_subnets = module.platform.private_subnets + vpc_id = module.platform.vpc_id + account_id = module.platform.aws_caller_identity.account_id + } + + cluster_arn = aws_ecs_cluster.test.arn + + image = "public.ecr.aws/nginx/nginx:latest" + cpu = 256 + memory = 512 + + # Old-style: no name on port mapping — exactly as existing callers pass it + port_mappings = [ + { + containerPort = 8081 + protocol = "tcp" + } + ] + + health_check = { + command = ["CMD-SHELL", "curl -f http://localhost:8081/ || exit 1"] + interval = 30 + retries = 3 + startPeriod = 15 + timeout = 5 + } + + task_role_arn = aws_iam_role.task.arn + security_groups = [aws_security_group.ecs_task.id] + + desired_count = 1 + force_new_deployment = false + + container_environment = [] + container_secrets = [] + + # ------------------------------------------------------- + # OLD INTERFACE — load_balancers passed directly + # alb_listener_arn is NOT set, so enable_alb_integration = false + # local.use_external_load_balancers = true → old dynamic block fires + # ------------------------------------------------------- + load_balancers = [ + { + target_group_arn = aws_lb_target_group.legacy_test.arn + container_name = "tftesting-legacy" + container_port = 8081 + } + ] + + # No alb_listener_arn, no alb_port_name, no alb_path_patterns + # No Service Connect for this one — keep it simple + enable_ecs_service_connect = false + + deployment_circuit_breaker = { + enable = true + rollback = true + } +} \ No newline at end of file diff --git a/terraform/services/tftesting/ecs-stack/outputs.tf b/terraform/services/tftesting/ecs-stack/outputs.tf new file mode 100644 index 00000000..0b010f7f --- /dev/null +++ b/terraform/services/tftesting/ecs-stack/outputs.tf @@ -0,0 +1,8 @@ +output "cluster_arn" { + description = "ARN of the test ECS cluster" + value = aws_ecs_cluster.test.arn +} + +output "task_role_arn" { + value = aws_iam_role.task.arn +} diff --git a/terraform/services/tftesting/ecs-stack/tofu.tf b/terraform/services/tftesting/ecs-stack/tofu.tf new file mode 100644 index 00000000..143f1fb9 --- /dev/null +++ b/terraform/services/tftesting/ecs-stack/tofu.tf @@ -0,0 +1,20 @@ +provider "aws" { + region = "us-east-1" + default_tags { + tags = module.platform.default_tags + } +} + +provider "aws" { + alias = "secondary" + region = "us-west-2" + default_tags { + tags = module.platform.default_tags + } +} + +terraform { + backend "s3" { + key = "tftesting/ecs-stack/terraform.tfstate" + } +} diff --git a/terraform/services/tftesting/service/main.tf b/terraform/services/tftesting/service/main.tf deleted file mode 100644 index bdd0d245..00000000 --- a/terraform/services/tftesting/service/main.tf +++ /dev/null @@ -1,125 +0,0 @@ -locals { - default_tags = module.platform.default_tags -} - -module "platform" { - source = "../../../modules/platform" - providers = { aws = aws, aws.secondary = aws.secondary } - - app = "cdap" - env = "test" - root_module = "https://github.com/CMSgov/cdap/tree/main/terraform/services/tftesting/service" - service = "tftesting" -} - -# ------------------------------------------------------- -# Cluster -# ------------------------------------------------------- -resource "aws_ecs_cluster" "test" { - name = "cdap-test-tftesting-cluster" - - # Service Connect requires a default namespace on the cluster - service_connect_defaults { - namespace = aws_service_discovery_http_namespace.test.arn - } -} - -# ------------------------------------------------------- -# Cloud Map Namespace (required for Service Connect) -# ------------------------------------------------------- -resource "aws_service_discovery_http_namespace" "test" { - name = "cdap-test-tftesting" - description = "Service Connect namespace for ECS module testing" -} - -# ------------------------------------------------------- -# Security Group — sourcing vpc_id from platform module -# ------------------------------------------------------- -resource "aws_security_group" "ecs_task" { - name = "cdap-test-tftesting-ecs-module-sg" - description = "Allow inbound HTTP for ECS task test" - vpc_id = module.platform.vpc_id # <-- from platform module - - ingress { - description = "HTTP inbound" - from_port = 8080 - to_port = 8080 - protocol = "tcp" - cidr_blocks = [module.platform.vpc_cidr] # or hardcode if platform doesn't expose cidr - } - - egress { - description = "Allow all outbound" - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - } -} - -# ------------------------------------------------------- -# ECS Service Module -# ------------------------------------------------------- -module "service" { - source = "../../../modules/service/" - - # ------------------------------------------------------- - # Platform — passed through from your platform module - # ------------------------------------------------------- - platform = { - app = module.platform.app - env = module.platform.env - service = "tftesting" - - kms_alias_primary = { - target_key_arn = module.platform.kms_alias_primary.target_key_arn - } - - primary_region = { - name = module.platform.primary_region.name - } - - private_subnets = module.platform.private_subnets - vpc_id = module.platform.vpc_id - } - - cluster_arn = aws_ecs_cluster.test.arn - - image = "public.ecr.aws/nginx/nginx:latest" - cpu = 256 - memory = 512 - - port_mappings = [ - { - name = "http" - containerPort = 8080 - protocol = "tcp" - appProtocol = "http" - } - ] - service_connect_port_name = "http" - health_check = { - command = ["CMD-SHELL", "curl -f http://localhost:8080/ || exit 1"] - interval = 30 - retries = 3 - startPeriod = 15 - timeout = 5 - } - - task_role_arn = aws_iam_role.task.arn - security_groups = [aws_security_group.ecs_task.id] - desired_count = 1 - force_new_deployment = false - container_environment = [] - container_secrets = [] - - # ------------------------------------------------------- - # Service Connect Testing - # ------------------------------------------------------- - enable_ecs_service_connect = true - service_connect_namespace = aws_service_discovery_http_namespace.test.arn - deployment_circuit_breaker = { - enable = true - rollback = true - } -} diff --git a/terraform/services/tftesting/service/outputs.tf b/terraform/services/tftesting/service/outputs.tf deleted file mode 100644 index 10ce44c0..00000000 --- a/terraform/services/tftesting/service/outputs.tf +++ /dev/null @@ -1,17 +0,0 @@ -output "cluster_arn" { - description = "ARN of the test ECS cluster" - value = aws_ecs_cluster.test.arn -} - -output "service_name" { - description = "Name of the ECS service created by the module" - value = module.ecs_service.service_name # adjust to your module's output name -} - -output "task_role_arn" { - value = aws_iam_role.task.arn -} - -output "execution_role_arn" { - value = aws_iam_role.execution.arn -} diff --git a/terraform/services/tftesting/service/terraform.tf b/terraform/services/tftesting/service/terraform.tf deleted file mode 100644 index e69de29b..00000000 diff --git a/terraform/services/tftesting/service/tofu.tf b/terraform/services/tftesting/service/tofu.tf deleted file mode 100644 index 4dedb04e..00000000 --- a/terraform/services/tftesting/service/tofu.tf +++ /dev/null @@ -1,26 +0,0 @@ -terraform { - required_version = ">= 1.5.0" - - required_providers { - aws = { - source = "hashicorp/aws" - version = ">= 5.0" - } - } -} - -provider "aws" { - region = "us-east-1" - default_tags { - tags = local.default_tags - } -} - -provider "aws" { - alias = "secondary" - - region = "us-west-2" - default_tags { - tags = local.default_tags - } -} \ No newline at end of file From 0913810d75e7898c377c9bc222888082acecf3eb Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 21 Apr 2026 02:06:26 -0400 Subject: [PATCH 9/9] Enable backwards compatibility with existing modules. Allowing teams to configure service connect and update with incoming changes. --- terraform/modules/service/iam.tf | 39 +++++---------- terraform/modules/service/locals.tf | 9 ++-- terraform/modules/service/main.tf | 21 ++++++-- terraform/modules/service/variables.tf | 66 +++++++++++++++++++++++++- 4 files changed, 99 insertions(+), 36 deletions(-) diff --git a/terraform/modules/service/iam.tf b/terraform/modules/service/iam.tf index fa294149..2d3fe775 100644 --- a/terraform/modules/service/iam.tf +++ b/terraform/modules/service/iam.tf @@ -59,11 +59,6 @@ data "aws_iam_policy_document" "execution" { # Service Connect Role IAM #--------------------------- -resource "aws_iam_role_policy_attachment" "service-connect" { - role = aws_iam_role.service_connect.name - policy_arn = aws_iam_role_policy.service_connect.arn -} - resource "aws_iam_role" "service_connect" { count = var.enable_ecs_service_connect ? 1 : 0 name = "${local.service_name_full}-service-connect" @@ -78,12 +73,17 @@ resource "aws_iam_role" "service_connect" { }) } -# encrypted certificate lifecycle and storage -resource "aws_iam_role_policy" "service_connect" { - count = var.enable_ecs_service_connect ? 1 : 0 - name = "${local.service_name_full}-service-connect" - role = aws_iam_role.service_connect[0].name - policy = data.aws_iam_policy_document.service_connect.json +resource "aws_iam_policy" "service_connect" { + count = var.enable_ecs_service_connect ? 1 : 0 + name = "${local.service_name_full}-service-connect" + description = "Base permissions for ECS Service Connect TLS lifecycle" + policy = data.aws_iam_policy_document.service_connect.json +} + +resource "aws_iam_role_policy_attachment" "service_connect" { + count = var.enable_ecs_service_connect ? 1 : 0 + role = aws_iam_role.service_connect[0].name + policy_arn = aws_iam_policy.service_connect[0].arn } data "aws_iam_policy_document" "service_connect" { @@ -95,7 +95,7 @@ data "aws_iam_policy_document" "service_connect" { "acm-pca:DescribeCertificateAuthority", "acm-pca:IssueCertificate" ] - resources = [data.aws_ram_resource_share.pace_ca.resource_arns] + resources = data.aws_ram_resource_share.pace_ca.resource_arns } statement { @@ -108,21 +108,6 @@ data "aws_iam_policy_document" "service_connect" { resources = ["arn:aws:acm:${var.platform.primary_region.name}:${var.platform.account_id}:certificate/*"] } - statement { - actions = [ - "secretsmanager:CreateSecret", - "secretsmanager:TagResource", - "secretsmanager:DescribeSecret", - "secretsmanager:UpdateSecret", - "secretsmanager:GetSecretValue", - "secretsmanager:PutSecretValue", - "secretsmanager:DeleteSecret", - "secretsmanager:RotateSecret", - "secretsmanager:UpdateSecretVersionStage" - ] - resources = ["arn:aws:secretsmanager:${var.platform.primary_region.name}:${data.aws_caller_identity.current.account_id}:secret:ecs-sc!*"] - } - statement { sid = "AllowKMSDecrypt" actions = [ diff --git a/terraform/modules/service/locals.tf b/terraform/modules/service/locals.tf index c68894a7..da4a9b95 100644 --- a/terraform/modules/service/locals.tf +++ b/terraform/modules/service/locals.tf @@ -9,9 +9,12 @@ locals { if pm.name != null && pm.containerPort != null } - sc_port_name = coalesce( - var.service_connect_port_name, - try([for pm in coalesce(var.port_mappings, []) : pm.name if pm.name != null][0], null) + sc_port_name = try( + coalesce( + var.service_connect_port_name, + try([for pm in coalesce(var.port_mappings, []) : pm.name if pm.name != null][0], null) + ), + null ) # ALB integration is active when a listener ARN is provided diff --git a/terraform/modules/service/main.tf b/terraform/modules/service/main.tf index 4c9b0aa9..d0076008 100644 --- a/terraform/modules/service/main.tf +++ b/terraform/modules/service/main.tf @@ -1,3 +1,7 @@ +locals { + use_external_load_balancers = var.load_balancers != null && !local.enable_alb_integration +} + data "aws_ram_resource_share" "pace_ca" { resource_owner = "OTHER-ACCOUNTS" name = "pace-ca-g1" @@ -87,11 +91,21 @@ resource "aws_ecs_service" "this" { for_each = local.enable_alb_integration ? [1] : [] content { target_group_arn = aws_lb_target_group.this[0].arn - container_name = var.service_name + container_name = var.service_name != null ? var.service_name : local.service_name container_port = local.alb_container_port } } + # Old interface: caller-provided load_balancers (deprecated, maintained for backwards compatibility) + dynamic "load_balancer" { + for_each = local.use_external_load_balancers ? coalesce(var.load_balancers, []) : [] + content { + target_group_arn = load_balancer.value.target_group_arn + container_name = coalesce(load_balancer.value.container_name, local.service_name) # + container_port = load_balancer.value.container_port + } + } + dynamic "service_connect_configuration" { for_each = var.enable_ecs_service_connect ? [1] : [] content { @@ -112,7 +126,7 @@ resource "aws_ecs_service" "this" { role_arn = aws_iam_role.service_connect[0].arn issuer_cert_authority { - aws_pca_authority_arn = [one(data.aws_ram_resource_share.pace_ca.resource_arns)] + aws_pca_authority_arn = one(data.aws_ram_resource_share.pace_ca.resource_arns) } } } @@ -132,7 +146,7 @@ resource "aws_ecs_service" "this" { } lifecycle { - ignore_changes = var.ignore_desired_count_changes ? [desired_count] : [] + ignore_changes = [desired_count] } } @@ -177,7 +191,6 @@ resource "aws_lb_target_group" "this" { } depends_on = [ - aws_lb_listener_rule.this, aws_iam_role_policy_attachment.service_connect ] } diff --git a/terraform/modules/service/variables.tf b/terraform/modules/service/variables.tf index ba794bfd..2b175d26 100644 --- a/terraform/modules/service/variables.tf +++ b/terraform/modules/service/variables.tf @@ -82,6 +82,26 @@ variable "health_check_grace_period_seconds" { type = number } +variable "deployment_minimum_healthy_percent" { + type = number + default = 100 + description = <<-EOT + Lower limit (as a percentage of desired_count) of the number of running tasks + that must remain healthy during a deployment. + Default is 100 — no tasks are taken down before new ones are healthy. + EOT +} + +variable "deployment_maximum_percent" { + type = number + default = 200 + description = <<-EOT + Upper limit (as a percentage of desired_count) of the number of running tasks + that can exist during a deployment. + Default is 200 — allows doubling the task count during a rolling deploy. + EOT +} + variable "desired_count" { description = "Number of instances of the task definition to place and keep running." type = number @@ -111,6 +131,27 @@ variable "cpu_architecture" { default = "ARM64" } +variable "load_balancers" { + description = "DEPRECATED. Use alb_listener_arn and related variables. container_name is optional — defaults to the module's resolved service name." + type = list(object({ + target_group_arn = string + container_name = optional(string) + container_port = number + })) + default = null +} + +variable "alb_listener_arn" { + type = string + default = null + description = <<-EOT + ARN of the ALB HTTPS listener to attach a listener rule to. + When set, the module creates an aws_lb_target_group and aws_lb_listener_rule + and wires the ECS service to the ALB. + When null, no ALB integration is created. + EOT +} + variable "alb_port_name" { type = string default = null @@ -142,6 +183,24 @@ variable "alb_health_check" { default = {} } +variable "alb_priority" { + type = number + default = null + + validation { + condition = var.alb_priority == null || (var.alb_priority >= 1 && var.alb_priority <= 50000) + error_message = "alb_priority must be between 1 and 50000." + } + + description = "Listener rule priority (1–50000). Required when alb_listener_arn is set." +} + +variable "alb_path_patterns" { + type = list(string) + default = null + description = "Path pattern conditions for the ALB listener rule. Required when alb_listener_arn is set." +} + # reference: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html#task_size variable "memory" { description = "Amount (in MiB) of memory used by the task." @@ -167,6 +226,8 @@ variable "platform" { primary_region = object({ name = string }) private_subnets = map(object({ id = string })) service = string + account_id = string + vpc_id = string }) } @@ -227,8 +288,9 @@ variable "volumes" { access_point_id = optional(string) iam = optional(string) })) - file_system_id = string - root_directory = optional(string) + file_system_id = string + root_directory = optional(string) + transit_encryption = optional(string) # deprecated: accepted but ignored, always ENABLED })) host_path = optional(string) name = string