Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,44 @@ module "ecs_apps" {
on_demand_percentage = 0
asg_min = 1
asg_max = 4
asg_target_capacity = 50
asg_target_capacity = 50

# Path-based routing example
alb_listener_rules = [
{
path_pattern = "/api/service1/*"
target_group_arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/service1/abcdef1234567890"
priority = 100
},
{
path_pattern = "/api/service2/*"
target_group_arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/service2/abcdef1234567890"
priority = 110
host_header = "example.com" # Optional: Add host-based routing
}
]

# For internal ALB (if enabled)
alb_internal_listener_rules = [
{
path_pattern = "/internal-api/*"
target_group_arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/internal-service/abcdef1234567890"
priority = 100
}
]
}
```

### Path-Based Routing

This module now supports path-based routing for both external and internal ALBs. This allows you to run multiple services in a single ECS cluster with different URL paths. The default action of the ALB listener will still forward to the default target group, but you can define rules to forward specific paths to different target groups.

To use path-based routing, define the `alb_listener_rules` variable with a list of objects containing:
- `path_pattern`: The path pattern to match (e.g., "/api/*")
- `target_group_arn`: The ARN of the target group to forward requests to
- `priority`: The priority of the rule (lower numbers are evaluated first)
- `host_header`: (Optional) If specified, the rule will only match requests with this host header

<!--- BEGIN_TF_DOCS --->

## Requirements
Expand Down Expand Up @@ -75,7 +109,9 @@ module "ecs_apps" {
| alb\_enable\_deletion\_protection | Enable deletion protection for ALBs | `bool` | `false` | no |
| alb\_http\_listener | Whether to enable HTTP listeners | `bool` | `true` | no |
| alb\_internal | Deploys a second internal ALB for private APIs. | `bool` | `false` | no |
| alb\_internal\_listener\_rules | A list of maps describing the Listener Rules for path-based routing on the internal ALB | <pre>list(object({<br> path_pattern = string<br> target_group_arn = string<br> priority = number<br> host_header = optional(string)<br> }))</pre> | `[]` | no |
| alb\_internal\_ssl\_policy | The name of the SSL Policy for the listener. Required if protocol is HTTPS or TLS. | `string` | `"ELBSecurityPolicy-TLS-1-2-Ext-2018-06"` | no |
| alb\_listener\_rules | A list of maps describing the Listener Rules for path-based routing on the external ALB | <pre>list(object({<br> path_pattern = string<br> target_group_arn = string<br> priority = number<br> host_header = optional(string)<br> }))</pre> | `[]` | no |
| alb\_only | Whether to deploy only an alb and no cloudFront or not with the cluster. | `bool` | `false` | no |
| alb\_sg\_allow\_alb\_test\_listener | Whether to allow world access to the test listeners | `bool` | `true` | no |
| alb\_sg\_allow\_egress\_https\_world | Whether to allow ALB to access HTTPS endpoints - needed when using OIDC authentication | `bool` | `true` | no |
Expand Down Expand Up @@ -146,10 +182,12 @@ module "ecs_apps" {
| alb\_internal\_id | n/a |
| alb\_internal\_listener\_https\_arn | n/a |
| alb\_internal\_listener\_test\_traffic\_arn | n/a |
| alb\_internal\_path\_based\_routing\_rules | IDs of the path-based routing rules for the internal ALB |
| alb\_internal\_secgrp\_id | n/a |
| alb\_internal\_zone\_id | n/a |
| alb\_listener\_https\_arn | n/a |
| alb\_listener\_test\_traffic\_arn | n/a |
| alb\_path\_based\_routing\_rules | IDs of the path-based routing rules for the external ALB |
| alb\_secgrp\_id | n/a |
| alb\_zone\_id | n/a |
| ecs\_arn | n/a |
Expand Down
10 changes: 10 additions & 0 deletions _outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ output "alb_internal_listener_test_traffic_arn" {
value = try(aws_lb_listener.ecs_test_https_internal.*.arn, "")
}

output "alb_path_based_routing_rules" {
value = try(aws_lb_listener_rule.path_based_routing.*.id, [])
description = "IDs of the path-based routing rules for the external ALB"
}

output "alb_internal_path_based_routing_rules" {
value = try(aws_lb_listener_rule.path_based_routing_internal.*.id, [])
description = "IDs of the path-based routing rules for the internal ALB"
}

output "ecs_nodes_secgrp_id" {
value = aws_security_group.ecs_nodes.id
}
Expand Down
22 changes: 22 additions & 0 deletions _variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -389,4 +389,26 @@ variable "idle_timeout" {
type = number
default = 400
description = "IDLE time for ALB on seconds."
}

variable "alb_listener_rules" {
description = "A list of maps describing the Listener Rules for path-based routing on the external ALB"
type = list(object({
path_pattern = string
target_group_arn = string
priority = number
host_header = optional(string)
}))
default = []
}

variable "alb_internal_listener_rules" {
description = "A list of maps describing the Listener Rules for path-based routing on the internal ALB"
type = list(object({
path_pattern = string
target_group_arn = string
priority = number
host_header = optional(string)
}))
default = []
}
139 changes: 139 additions & 0 deletions alb-listener-rules.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
resource "aws_lb_listener_rule" "path_based_routing" {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Description: Repetitive code structure across multiple resources. Consider using a module or local to reduce repetition.

Severity: Medium

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix addresses the repetitive code structure by introducing a local variable listener_rule_config that consolidates the configuration for all four listener rules. It then uses a single aws_lb_listener_rule resource with a for_each loop to create the rules based on this configuration. This approach significantly reduces code duplication and improves maintainability.

Suggested change
resource "aws_lb_listener_rule" "path_based_routing" {
locals {
listener_rule_config = {
default = {
listener_arn = aws_lb_listener.ecs_https[0].arn
rules = var.alb_listener_rules
condition = var.alb ? length(var.alb_listener_rules) : 0
}
test = {
listener_arn = aws_lb_listener.ecs_test_https[0].arn
rules = var.alb_listener_rules
condition = var.alb && var.alb_test_listener ? length(var.alb_listener_rules) : 0
}
internal = {
listener_arn = aws_lb_listener.ecs_https_internal[0].arn
rules = var.alb_internal_listener_rules
condition = var.alb_internal ? length(var.alb_internal_listener_rules) : 0
}
internal_test = {
listener_arn = aws_lb_listener.ecs_test_https_internal[0].arn
rules = var.alb_internal_listener_rules
condition = var.alb_internal && var.alb_test_listener ? length(var.alb_internal_listener_rules) : 0
}
}
}
resource "aws_lb_listener_rule" "path_based_routing" {
for_each = local.listener_rule_config
count = each.value.condition
listener_arn = each.value.listener_arn
priority = each.value.rules[count.index].priority
action {
type = "forward"
target_group_arn = each.value.rules[count.index].target_group_arn
}
condition {
path_pattern {
values = [each.value.rules[count.index].path_pattern]
}
}
dynamic "condition" {
for_each = each.value.rules[count.index].host_header != null ? [1] : []
content {
host_header {
values = [each.value.rules[count.index].host_header]
}
}
}
tags = merge(
var.tags,
{
"Terraform" = true
},
)
}

count = var.alb ? length(var.alb_listener_rules) : 0

listener_arn = aws_lb_listener.ecs_https[0].arn
priority = var.alb_listener_rules[count.index].priority

action {
type = "forward"
target_group_arn = var.alb_listener_rules[count.index].target_group_arn
}

condition {
path_pattern {
values = [var.alb_listener_rules[count.index].path_pattern]
}
}

dynamic "condition" {
for_each = var.alb_listener_rules[count.index].host_header != null ? [1] : []

content {
host_header {
values = [var.alb_listener_rules[count.index].host_header]
}
}
}

tags = merge(
var.tags,
{
"Terraform" = true
},
)
}

resource "aws_lb_listener_rule" "path_based_routing_test" {
count = var.alb && var.alb_test_listener ? length(var.alb_listener_rules) : 0

listener_arn = aws_lb_listener.ecs_test_https[0].arn
priority = var.alb_listener_rules[count.index].priority

action {
type = "forward"
target_group_arn = var.alb_listener_rules[count.index].target_group_arn
}

condition {
path_pattern {
values = [var.alb_listener_rules[count.index].path_pattern]
}
}

dynamic "condition" {
for_each = var.alb_listener_rules[count.index].host_header != null ? [1] : []

content {
host_header {
values = [var.alb_listener_rules[count.index].host_header]
}
}
}

tags = merge(
var.tags,
{
"Terraform" = true
},
)
}

resource "aws_lb_listener_rule" "path_based_routing_internal" {
count = var.alb_internal ? length(var.alb_internal_listener_rules) : 0

listener_arn = aws_lb_listener.ecs_https_internal[0].arn
priority = var.alb_internal_listener_rules[count.index].priority

action {
type = "forward"
target_group_arn = var.alb_internal_listener_rules[count.index].target_group_arn
}

condition {
path_pattern {
values = [var.alb_internal_listener_rules[count.index].path_pattern]
}
}

dynamic "condition" {
for_each = var.alb_internal_listener_rules[count.index].host_header != null ? [1] : []

content {
host_header {
values = [var.alb_internal_listener_rules[count.index].host_header]
}
}
}

tags = merge(
var.tags,
{
"Terraform" = true
},
)
}

resource "aws_lb_listener_rule" "path_based_routing_internal_test" {
count = var.alb_internal && var.alb_test_listener ? length(var.alb_internal_listener_rules) : 0

listener_arn = aws_lb_listener.ecs_test_https_internal[0].arn
priority = var.alb_internal_listener_rules[count.index].priority

action {
type = "forward"
target_group_arn = var.alb_internal_listener_rules[count.index].target_group_arn
}

condition {
path_pattern {
values = [var.alb_internal_listener_rules[count.index].path_pattern]
}
}

dynamic "condition" {
for_each = var.alb_internal_listener_rules[count.index].host_header != null ? [1] : []

content {
host_header {
values = [var.alb_internal_listener_rules[count.index].host_header]
}
}
}

tags = merge(
var.tags,
{
"Terraform" = true
},
)
}
59 changes: 59 additions & 0 deletions alb-listener-rules_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package test

import (
"testing"

"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)

func TestAlbListenerRules(t *testing.T) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Description: The test function is long and could be split into smaller, more focused functions for better readability and maintainability. Consider extracting the Terraform configuration setup into a separate function.

Severity: Low

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix addresses the comment by extracting the Terraform configuration setup into a separate function called setupTerraformOptions(). This improves the readability and maintainability of the test function by reducing its length and separating concerns. The main TestAlbListenerRules function now focuses on running the test and asserting the results, while the configuration setup is handled in a dedicated function.

Suggested change
func TestAlbListenerRules(t *testing.T) {
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestAlbListenerRules(t *testing.T) {
t.Parallel()
terraformOptions := setupTerraformOptions()
// At the end of the test, run `terraform destroy` to clean up any resources that were created
defer terraform.Destroy(t, terraformOptions)
// This will run `terraform init` and `terraform apply` and fail the test if there are any errors
terraform.InitAndApply(t, terraformOptions)
// Run `terraform output` to get the values of output variables
albPathBasedRoutingRules := terraform.OutputList(t, terraformOptions, "alb_path_based_routing_rules")
// Verify we have the correct number of rules
assert.Equal(t, 2, len(albPathBasedRoutingRules))
}
func setupTerraformOptions() *terraform.Options {
return &terraform.Options{
// The path to where our Terraform code is located
TerraformDir: ".",
// Variables to pass to our Terraform code using -var options
Vars: map[string]interface{}{
"name": "test-cluster",
"vpc_id": "vpc-12345678",
"private_subnet_ids": []string{"subnet-1", "subnet-2"},
"public_subnet_ids": []string{"subnet-3", "subnet-4"},
"secure_subnet_ids": []string{"subnet-5", "subnet-6"},
"certificate_arn": "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012",
"alb": true,
"alb_listener_rules": []map[string]interface{}{
{
"path_pattern": "/api/service1/*",
"target_group_arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/service1/abcdef1234567890",
"priority": 100,
},
{
"path_pattern": "/api/service2/*",
"target_group_arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/service2/abcdef1234567890",
"priority": 110,
"host_header": "example.com",
},
},
},
// Variables to pass to our Terraform code using -var-file options
VarFiles: []string{},
// Disable colors in Terraform commands so its easier to parse stdout/stderr
NoColor: true,
}
}

t.Parallel()

terraformOptions := &terraform.Options{
// The path to where our Terraform code is located
TerraformDir: ".",

// Variables to pass to our Terraform code using -var options
Vars: map[string]interface{}{
"name": "test-cluster",
"vpc_id": "vpc-12345678",
"private_subnet_ids": []string{"subnet-1", "subnet-2"},
"public_subnet_ids": []string{"subnet-3", "subnet-4"},
"secure_subnet_ids": []string{"subnet-5", "subnet-6"},
"certificate_arn": "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012",
"alb": true,
"alb_listener_rules": []map[string]interface{}{
{
"path_pattern": "/api/service1/*",
"target_group_arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/service1/abcdef1234567890",
"priority": 100,
},
{
"path_pattern": "/api/service2/*",
"target_group_arn": "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/service2/abcdef1234567890",
"priority": 110,
"host_header": "example.com",
},
},
},

// Variables to pass to our Terraform code using -var-file options
VarFiles: []string{},

// Disable colors in Terraform commands so its easier to parse stdout/stderr
NoColor: true,
}

// At the end of the test, run `terraform destroy` to clean up any resources that were created
defer terraform.Destroy(t, terraformOptions)

// This will run `terraform init` and `terraform apply` and fail the test if there are any errors
terraform.InitAndApply(t, terraformOptions)

// Run `terraform output` to get the values of output variables
albPathBasedRoutingRules := terraform.OutputList(t, terraformOptions, "alb_path_based_routing_rules")

// Verify we have the correct number of rules
assert.Equal(t, 2, len(albPathBasedRoutingRules))
}
77 changes: 77 additions & 0 deletions example/ecs-app-path-routing.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Example of using path-based routing with multiple services

# First service - WordPress
module "ecs_app_wordpress_01" {
source = "git::https://github.com/DNXLabs/terraform-aws-ecs-app.git?ref=1.5.0"
vpc_id = local.workspace["vpc_id"]
cluster_name = module.ecs_apps.ecs_name
service_role_arn = module.ecs_apps.ecs_service_iam_role_arn
task_role_arn = module.ecs_apps.ecs_task_iam_role_arn
alb_listener_https_arn = element(module.ecs_apps.alb_listener_https_arn, 0)
alb_dns_name = element(module.ecs_apps.alb_dns_name, 0)
name = "wordpress-01"
image = "nginxdemos/hello:latest"
container_port = 80
hostname = "wp01.labs.dnx.host"
hostname_blue = "wp01-blue.labs.dnx.host"
hostname_origin = "wp01-origin.labs.dnx.host"
hosted_zone = "labs.dnx.host"
certificate_arn = local.workspace["cf_certificate_arn"]
healthcheck_path = "/readme.html"
service_health_check_grace_period_seconds = 120
}

# Second service - API
module "ecs_app_api" {
source = "git::https://github.com/DNXLabs/terraform-aws-ecs-app.git?ref=1.5.0"
vpc_id = local.workspace["vpc_id"]
cluster_name = module.ecs_apps.ecs_name
service_role_arn = module.ecs_apps.ecs_service_iam_role_arn
task_role_arn = module.ecs_apps.ecs_task_iam_role_arn
alb_listener_https_arn = element(module.ecs_apps.alb_listener_https_arn, 0)
alb_dns_name = element(module.ecs_apps.alb_dns_name, 0)
name = "api"
image = "nginxdemos/hello:latest"
container_port = 80
hostname = "api.labs.dnx.host"
hostname_blue = "api-blue.labs.dnx.host"
hostname_origin = "api-origin.labs.dnx.host"
hosted_zone = "labs.dnx.host"
certificate_arn = local.workspace["cf_certificate_arn"]
healthcheck_path = "/health"
service_health_check_grace_period_seconds = 120
}

# Configure path-based routing in the ECS cluster module
locals {
path_based_routing_rules = [
{
path_pattern = "/wordpress/*"
target_group_arn = module.ecs_app_wordpress_01.target_group_arn
priority = 100
},
{
path_pattern = "/api/*"
target_group_arn = module.ecs_app_api.target_group_arn
priority = 110
}
]
}

# Update the ECS cluster module to use path-based routing
module "ecs_apps" {
source = "git::https://github.com/DNXLabs/terraform-aws-ecs.git?ref=0.2.0"
name = local.workspace["cluster_name"]
instance_type_1 = "t3.large"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Description: The ECS cluster module uses outdated instance types, which may not provide optimal performance. Consider updating instance types to more recent and cost-effective options, such as t3.large, t3a.large, or m5.large.

Severity: Medium

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix updates the instance types used in the ECS cluster module to more recent and cost-effective options. Specifically, it changes instance_type_2 from "t2.large" to "t3a.large" and instance_type_3 from "m2.xlarge" to "m5.large". This addresses the comment by using more up-to-date instance types that can provide better performance and potentially lower costs.

Suggested change
instance_type_1 = "t3.large"
source = "git::https://github.com/DNXLabs/terraform-aws-ecs.git?ref=0.2.0"
name = local.workspace["cluster_name"]
instance_type_1 = "t3.large"
instance_type_2 = "t3a.large"
instance_type_3 = "m5.large"
vpc_id = local.workspace["vpc_id"]
private_subnet_ids = [split(",", local.workspace["private_subnet_ids"])]
public_subnet_ids = [split(",", local.workspace["public_subnet_ids"])]

instance_type_2 = "t2.large"
instance_type_3 = "m2.xlarge"
vpc_id = local.workspace["vpc_id"]
private_subnet_ids = [split(",", local.workspace["private_subnet_ids"])]
public_subnet_ids = [split(",", local.workspace["public_subnet_ids"])]
secure_subnet_ids = [split(",", local.workspace["secure_subnet_ids"])]
certificate_arn = local.workspace["alb_certificate_arn"]
on_demand_percentage = 0

# Path-based routing configuration
alb_listener_rules = local.path_based_routing_rules
}
Loading