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/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.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.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 |
diff --git a/terraform/modules/service/iam.tf b/terraform/modules/service/iam.tf
new file mode 100644
index 00000000..2d3fe775
--- /dev/null
+++ b/terraform/modules/service/iam.tf
@@ -0,0 +1,119 @@
+# --------------------------------
+# 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" "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" }
+ }]
+ })
+}
+
+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" {
+ 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 {
+ sid = "AllowKMSDecrypt"
+ actions = [
+ "kms:Decrypt",
+ "kms:GenerateDataKey"
+ ]
+ resources = [var.platform.kms_alias_primary.target_key_arn]
+ }
+}
diff --git a/terraform/modules/service/locals.tf b/terraform/modules/service/locals.tf
new file mode 100644
index 00000000..da4a9b95
--- /dev/null
+++ b/terraform/modules/service/locals.tf
@@ -0,0 +1,26 @@
+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 = 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
+ 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
+}
+
diff --git a/terraform/modules/service/main.tf b/terraform/modules/service/main.tf
index 535d8b96..d0076008 100644
--- a/terraform/modules/service/main.tf
+++ b/terraform/modules/service/main.tf
@@ -1,6 +1,10 @@
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}"
+ 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"
}
resource "aws_ecs_task_definition" "this" {
@@ -78,69 +82,133 @@ 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 = aws_lb_target_group.this[0].arn
+ 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 = load_balancer.value.container_name
+ container_name = coalesce(load_balancer.value.container_name, local.service_name) #
container_port = load_balancer.value.container_port
}
}
- deployment_minimum_healthy_percent = 100
+ dynamic "service_connect_configuration" {
+ for_each = var.enable_ecs_service_connect ? [1] : []
+ content {
+ enabled = true
+ namespace = var.service_connect_namespace
+
+ service {
+ discovery_name = local.service_name
+ port_name = local.sc_port_name
+
+ client_alias {
+ 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
-}
-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 = ["*"]
+ depends_on = [
+ aws_lb_listener_rule.this
+ ]
+
+ deployment_circuit_breaker {
+ enable = var.deployment_circuit_breaker.enable
+ rollback = var.deployment_circuit_breaker.rollback
}
- statement {
- actions = [
- "kms:Decrypt"
- ]
- resources = [var.platform.kms_alias_primary.target_key_arn]
- effect = "Allow"
+ lifecycle {
+ ignore_changes = [desired_count]
}
}
-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"
- }
- },
- ]
- })
+# -------------------------------------------------------
+# 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
+ }
+
+ tags = {
+ Name = "${local.service_name_full}-tg"
+ }
+
+ 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."
+ }
+ }
+
+ depends_on = [
+ aws_iam_role_policy_attachment.service_connect
+ ]
}
-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_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
+ }
+ }
}
diff --git a/terraform/modules/service/outputs.tf b/terraform/modules/service/outputs.tf
index e29f04ee..5dd7ad81 100644
--- a/terraform/modules/service/outputs.tf
+++ b/terraform/modules/service/outputs.tf
@@ -1,10 +1,34 @@
output "service" {
- description = "The ecs service for the given inputs."
+ description = "The ECS service resource."
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."
+ description = "The ECS task definition resource."
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
+}
+
+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/modules/service/variables.tf b/terraform/modules/service/variables.tf
index 1e404b7c..2b175d26 100644
--- a/terraform/modules/service/variables.tf
+++ b/terraform/modules/service/variables.tf
@@ -3,6 +3,56 @@ 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
+}
+
+# Define where this gets set by developers
+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" {
+ 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 "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)
+ 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)
+# -------------------------------------------------------
+
variable "container_environment" {
description = "The environment variables to pass to the container"
type = list(object({
@@ -32,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
@@ -39,7 +109,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
}
@@ -62,13 +132,73 @@ variable "cpu_architecture" {
}
variable "load_balancers" {
- description = "Load balancer(s) for use by the AWS ECS service."
+ 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 = string
+ container_name = optional(string)
container_port = number
}))
- default = []
+ 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
+ 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 = {}
+}
+
+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
@@ -96,6 +226,8 @@ variable "platform" {
primary_region = object({ name = string })
private_subnets = map(object({ id = string }))
service = string
+ account_id = string
+ vpc_id = string
})
}
@@ -143,7 +275,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
}
@@ -156,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
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",
diff --git a/terraform/services/tftesting/ecs-stack/iam.tf b/terraform/services/tftesting/ecs-stack/iam.tf
new file mode 100644
index 00000000..f237f127
--- /dev/null
+++ b/terraform/services/tftesting/ecs-stack/iam.tf
@@ -0,0 +1,85 @@
+# -------------------------------------------------------
+# 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.account_id}:/cdap/test/tftesting/*"
+ ]
+ }
+
+ # # -------------------------------------------------------
+ # # 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"
+ 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.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"
+ }
+}