From 3acbee7ff78641521c6928e3bb4f162a5536c006 Mon Sep 17 00:00:00 2001 From: mianava Date: Tue, 7 Apr 2026 11:09:55 -0400 Subject: [PATCH 1/6] 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 1d122d7de6157a04e2edb440b62878d5e629b975 Mon Sep 17 00:00:00 2001 From: mianava Date: Thu, 9 Apr 2026 11:40:54 -0400 Subject: [PATCH 2/6] 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 365c89ccf3e62fa97daaae1dd637e859902dbbd3 Mon Sep 17 00:00:00 2001 From: mianava Date: Thu, 9 Apr 2026 12:10:18 -0400 Subject: [PATCH 3/6] 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 6500cc39d14f46c44a9582616e91d1faa064a20f Mon Sep 17 00:00:00 2001 From: mianava Date: Thu, 9 Apr 2026 13:53:40 -0400 Subject: [PATCH 4/6] 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 36c6b343..8ee93275 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 5ae12d1618cbce0b71e8f7fc2dec23d2b0baad5a Mon Sep 17 00:00:00 2001 From: mianava Date: Thu, 9 Apr 2026 13:55:43 -0400 Subject: [PATCH 5/6] 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 9d5c3d1f2def25f5cb0e42ce0f8b4d656ffc61ff Mon Sep 17 00:00:00 2001 From: mianava Date: Fri, 10 Apr 2026 10:44:23 -0400 Subject: [PATCH 6/6] 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 }