diff --git a/README.md b/README.md index 24ca8fd..684aaa2 100644 --- a/README.md +++ b/README.md @@ -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 + ## Requirements @@ -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 |
list(object({
path_pattern = string
target_group_arn = string
priority = number
host_header = optional(string)
}))
| `[]` | 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 |
list(object({
path_pattern = string
target_group_arn = string
priority = number
host_header = optional(string)
}))
| `[]` | 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 | @@ -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 | diff --git a/_outputs.tf b/_outputs.tf index a31a832..f497aa9 100644 --- a/_outputs.tf +++ b/_outputs.tf @@ -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 } diff --git a/_variables.tf b/_variables.tf index 2d19670..946d8c6 100644 --- a/_variables.tf +++ b/_variables.tf @@ -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 = [] } \ No newline at end of file diff --git a/alb-listener-rules.tf b/alb-listener-rules.tf new file mode 100644 index 0000000..4c24f99 --- /dev/null +++ b/alb-listener-rules.tf @@ -0,0 +1,139 @@ +resource "aws_lb_listener_rule" "path_based_routing" { + 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 + }, + ) +} \ No newline at end of file diff --git a/alb-listener-rules_test.go b/alb-listener-rules_test.go new file mode 100644 index 0000000..622b811 --- /dev/null +++ b/alb-listener-rules_test.go @@ -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) { + 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)) +} \ No newline at end of file diff --git a/example/ecs-app-path-routing.tf b/example/ecs-app-path-routing.tf new file mode 100644 index 0000000..d1a7897 --- /dev/null +++ b/example/ecs-app-path-routing.tf @@ -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" + 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 +} \ No newline at end of file