Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
23 changes: 23 additions & 0 deletions terraform/modules/service/locals.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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
}

109 changes: 98 additions & 11 deletions terraform/modules/service/main.tf
Original file line number Diff line number Diff line change
@@ -1,8 +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}"
}

resource "aws_ecs_task_definition" "this" {
family = local.service_name_full
network_mode = "awsvpc"
Expand Down Expand Up @@ -78,24 +73,115 @@ 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
}
}

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 {
port_name = var.service_connect_port_name

client_alias {
port = local.port_map[var.service_connect_port_name]
dns_name = var.service_name
}
}
}
}

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 {
Expand Down Expand Up @@ -144,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
}

19 changes: 19 additions & 0 deletions terraform/modules/service/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
81 changes: 73 additions & 8 deletions terraform/modules/service/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,50 @@ 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" {
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)
# -------------------------------------------------------

variable "container_environment" {
description = "The environment variables to pass to the container"
type = list(object({
Expand Down Expand Up @@ -61,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
Expand Down