diff --git a/README.md b/README.md index 37a00cb0..56c00223 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,10 @@ sync by `node tools/ravion-modules/dist/src/cli.js readme` (enforced in CI, and | `rvn-aws-network` | VPC Network | v1.0.0 | `networking/vpc/` | | `rvn-aws-static` | Static Hosting | v1.0.0 | `hosting/static_site/` | | `rvn-cloudfront` | CloudFront CDN | v1.0.0 | `cdn/cloudfront/` | -| `rvn-ec2-service` | EC2 Service | v1.0.0 | `compute/ec2_service/` | -| `rvn-ecs-cluster` | ECS Cluster | v1.0.0 | `compute/ecs_cluster/` | -| `rvn-ecs-nlb` | ECS Network Service | v1.0.0 | `compute/ecs_service/` | -| `rvn-ecs-web` | ECS Web Service | v1.0.0 | `compute/ecs_service/` | +| `rvn-ec2-service` | EC2 Service | v2.0.0 | `compute/ec2_service/` | +| `rvn-ecs-cluster` | ECS Cluster | v2.0.0 | `compute/ecs_cluster/` | +| `rvn-ecs-nlb` | ECS Network Service | v2.0.0 | `compute/ecs_service/` | +| `rvn-ecs-web` | ECS Web Service | v2.0.0 | `compute/ecs_service/` | | `rvn-ecs-worker` | ECS Worker | v1.0.0 | `compute/ecs_service/` | | `rvn-efs` | EFS File System | v1.0.0 | `storage/efs/` | | `rvn-elasticache` | ElastiCache | v1.0.0 | `cache/elasticache/` | diff --git a/compute/ec2_service/rvn-ec2-service-definition.yml b/compute/ec2_service/rvn-ec2-service-definition.yml index 67dd96f3..a28262de 100644 --- a/compute/ec2_service/rvn-ec2-service-definition.yml +++ b/compute/ec2_service/rvn-ec2-service-definition.yml @@ -3,8 +3,8 @@ definition: name: EC2 Service description: Runs supervised workloads on a stable EC2 Auto Scaling Group, with optional shared ALB routing and switchable container or manual in-place deploys. release: - version: 1.0.0 - description: First stable release. + version: 2.0.0 + description: ECS cluster references now expose the cluster's load balancer arrays; the shared ALB fields default to the cluster's first public or private load balancer. module: inputs: - id: network @@ -271,16 +271,6 @@ module: description: Attach listener rules to the ALB in this cluster. mapped_inputs: - $include: ../../partials/inputs/ecs-service-cluster-alb-mapped-inputs.yml - - default: <> - id: public_alb_arn_suffix - immutable: true - label: Public ALB ARN suffix - type: string - - default: <> - id: private_alb_arn_suffix - immutable: true - label: Private ALB ARN suffix - type: string required: true show_when: http_traffic_enabled: true diff --git a/compute/ecs_cluster/.terraform.lock.hcl b/compute/ecs_cluster/.terraform.lock.hcl index 28ffb2fa..4a54aae4 100644 --- a/compute/ecs_cluster/.terraform.lock.hcl +++ b/compute/ecs_cluster/.terraform.lock.hcl @@ -3,9 +3,10 @@ provider "registry.opentofu.org/hashicorp/aws" { version = "6.39.0" - constraints = ">= 5.0.0" + constraints = ">= 6.0.0" hashes = [ "h1:c9SG8ZdYgzqpxORpTqeLFeXW4qQQ8GMGCcUkU+FAfQM=", + "h1:y1duxh2rH3FIV8ndSfMz8WXrlsrKO5XL5/+UdnYquUU=", "zh:00a6c0d8b5b86833087e367b632e9ab73fb8db9c43569020ebd0489dc2c919ce", "zh:05f2b56211f4c8a0b66a093d025187cbc7be086dedef62306f5a28290598ebdc", "zh:24d97a31d5ab814c33ed32a5b7674f1a15544b2367a95bddd00cfdd8d6b82740", diff --git a/compute/ecs_cluster/README.md b/compute/ecs_cluster/README.md index 5db87802..c08ab657 100644 --- a/compute/ecs_cluster/README.md +++ b/compute/ecs_cluster/README.md @@ -50,9 +50,13 @@ module "ecs" { fargate_spot_weight = 3 # Public ALB with HTTPS - public_alb_enabled = true - public_alb_https_enabled = true - public_alb_certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/abc123"] + public_albs = [ + { + name = "my-app-pub" + https_enabled = true + certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/abc123"] + } + ] } ``` @@ -84,7 +88,7 @@ module "ecs" { ec2_on_demand_percentage_above_base = 25 # Public ALB - public_alb_enabled = true + public_albs = [{ name = "my-app-pub" }] } ``` @@ -114,14 +118,27 @@ module "ecs" { ec2_max_size = 20 ec2_desired_capacity = 2 - # Both ALBs - public_alb_enabled = true - public_alb_https_enabled = true - public_alb_certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/abc123"] + # Multiple ALBs: services pick one by name + public_albs = [ + { + name = "my-app-pub" + https_enabled = true + certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/abc123"] + }, + { + name = "my-app-pub-api" + https_enabled = true + certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/def456"] + } + ] - private_alb_enabled = true - private_alb_https_enabled = true - private_alb_certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/xyz789"] + private_albs = [ + { + name = "my-app-priv" + https_enabled = true + certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/xyz789"] + } + ] tags = { Environment = "production" @@ -143,7 +160,7 @@ module "ecs" { public_subnet_ids = ["subnet-public-1", "subnet-public-2"] # Public NLB (listeners and target groups created by service modules) - public_nlb_enabled = true + public_nlbs = [{ name = "my-app-pub-nlb" }] } ``` @@ -160,8 +177,12 @@ module "ecs" { public_subnet_ids = ["subnet-public-1", "subnet-public-2"] # Public NLB (listeners and target groups created by service modules) - public_nlb_enabled = true - public_nlb_cross_zone_load_balancing_enabled = true + public_nlbs = [ + { + name = "my-app-pub-nlb" + cross_zone_load_balancing_enabled = true + } + ] } # Service modules create their own listeners and target groups @@ -171,7 +192,7 @@ module "api_service" { # ... service configuration ... load_balancer_attachment = { - nlb_arn = module.ecs.public_nlb_arn + nlb_arn = module.ecs.public_nlbs[0].arn nlb_listener = { port = 443 protocol = "TLS" @@ -253,58 +274,49 @@ module "api_service" { | ec2_managed_scaling_target_capacity | Target capacity percentage | `number` | `100` | no | | ec2_security_group_ids | Additional security groups for EC2 | `list(string)` | `[]` | no | -### Public ALB +### Application load balancers -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|----------| -| public_alb_enabled | Enable public ALB | `bool` | `false` | no | -| public_alb_https_enabled | Enable HTTPS listener | `bool` | `false` | no | -| public_alb_certificate_arns | ACM certificate ARNs for HTTPS. First ARN is the default certificate; the rest are attached for SNI | `list(string)` | `[]` | no | -| public_alb_ssl_policy | SSL policy for HTTPS | `string` | `"ELBSecurityPolicy-TLS13-1-2-2021-06"` | no | -| public_alb_idle_timeout | Idle timeout in seconds | `number` | `60` | no | -| public_alb_ingress_cidr_blocks | Allowed IPv4 CIDR blocks | `list(string)` | `["0.0.0.0/0"]` | no | -| public_alb_ingress_security_group_ids | Security group IDs allowed to access the public ALB | `list(string)` | `[]` | no | -| public_alb_access_logs_enabled | Enable access logs | `bool` | `false` | no | -| public_alb_access_logs_bucket_arn | S3 bucket ARN for access logs | `string` | `null` | no | -| public_alb_web_acl_arn | WAFv2 Web ACL ARN | `string` | `null` | no | - -### Private ALB +Each cluster can create any number of public and private ALBs. Services select one by name. | Name | Description | Type | Default | Required | |------|-------------|------|---------|----------| -| private_alb_enabled | Enable private ALB | `bool` | `false` | no | -| private_alb_https_enabled | Enable HTTPS listener | `bool` | `false` | no | -| private_alb_certificate_arns | ACM certificate ARNs for HTTPS. First ARN is the default certificate; the rest are attached for SNI | `list(string)` | `[]` | no | -| private_alb_ssl_policy | SSL policy for HTTPS | `string` | `"ELBSecurityPolicy-TLS13-1-2-2021-06"` | no | -| private_alb_idle_timeout | Idle timeout in seconds | `number` | `60` | no | -| private_alb_ingress_cidr_blocks | Allowed IPv4 CIDR blocks | `list(string)` | `["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]` | no | -| private_alb_ingress_security_group_ids | Security group IDs allowed to access the private ALB (e.g., CloudFront VPC origins) | `list(string)` | `[]` | no | -| private_alb_access_logs_enabled | Enable access logs | `bool` | `false` | no | -| private_alb_access_logs_bucket_arn | S3 bucket ARN for access logs | `string` | `null` | no | - -### Public NLB +| public_albs | Public (internet-facing) ALBs, one per entry | `list(object)` | `[]` | no | +| private_albs | Private (internal) ALBs, one per entry | `list(object)` | `[]` | no | + +Each ALB object supports: + +| Name | Description | Type | Default | +|------|-------------|------|---------| +| name | Load balancer name (defaults to a deterministic name derived from the cluster name and index) | `string` | `null` | +| https_enabled | Enable HTTPS listener | `bool` | `false` | +| certificate_arns | ACM certificate ARNs for HTTPS. First ARN is the default certificate; the rest are attached for SNI | `list(string)` | `[]` | +| ssl_policy | SSL policy for HTTPS | `string` | `"ELBSecurityPolicy-TLS13-1-2-2021-06"` | +| idle_timeout | Idle timeout in seconds | `number` | `60` | +| ingress_cidr_blocks | Allowed IPv4 CIDR blocks | `list(string)` | public: `["0.0.0.0/0"]`, private: RFC1918 ranges | +| ingress_ipv6_cidr_blocks | Allowed IPv6 CIDR blocks | `list(string)` | public: `["::/0"]`, private: `[]` | +| ingress_security_group_ids | Security group IDs allowed to access the ALB | `list(string)` | `[]` | +| access_logs_enabled | Enable access logs | `bool` | `false` | +| access_logs_bucket_arn | S3 bucket ARN for access logs | `string` | `null` | +| web_acl_arn | WAFv2 Web ACL ARN (public ALBs only) | `string` | `null` | + +### Network load balancers | Name | Description | Type | Default | Required | |------|-------------|------|---------|----------| -| public_nlb_enabled | Enable public NLB | `bool` | `false` | no | -| public_nlb_cross_zone_load_balancing_enabled | Enable cross-zone load balancing | `bool` | `false` | no | -| public_nlb_security_group_ids | Security groups to attach | `list(string)` | `[]` | no | -| public_nlb_access_logs_enabled | Enable access logs | `bool` | `false` | no | -| public_nlb_access_logs_bucket_arn | S3 bucket ARN for access logs | `string` | `null` | no | -| public_nlb_elastic_ips_enabled | Enable static IPs | `bool` | `false` | no | -| public_nlb_elastic_ip_allocation_ids | Elastic IP allocation IDs | `list(string)` | `[]` | no | +| public_nlbs | Public (internet-facing) NLBs, one per entry | `list(object)` | `[]` | no | +| private_nlbs | Private (internal) NLBs, one per entry | `list(object)` | `[]` | no | -### Private NLB +Each NLB object supports: -| Name | Description | Type | Default | Required | -|------|-------------|------|---------|----------| -| private_nlb_enabled | Enable private NLB | `bool` | `false` | no | -| private_nlb_cross_zone_load_balancing_enabled | Enable cross-zone load balancing | `bool` | `false` | no | -| private_nlb_security_group_ids | Security groups to attach | `list(string)` | `[]` | no | -| private_nlb_access_logs_enabled | Enable access logs | `bool` | `false` | no | -| private_nlb_access_logs_bucket_arn | S3 bucket ARN for access logs | `string` | `null` | no | -| private_nlb_elastic_ips_enabled | Enable static IPs | `bool` | `false` | no | -| private_nlb_elastic_ip_allocation_ids | Elastic IP allocation IDs | `list(string)` | `[]` | no | +| Name | Description | Type | Default | +|------|-------------|------|---------| +| name | Load balancer name (defaults to a deterministic name derived from the cluster name and index) | `string` | `null` | +| cross_zone_load_balancing_enabled | Enable cross-zone load balancing | `bool` | `false` | +| security_group_ids | Security groups to attach | `list(string)` | `[]` | +| access_logs_enabled | Enable access logs | `bool` | `false` | +| access_logs_bucket_arn | S3 bucket ARN for access logs | `string` | `null` | +| elastic_ips_enabled | Enable static IPs (public NLBs only) | `bool` | `false` | +| elastic_ip_allocation_ids | Elastic IP allocation IDs (public NLBs only) | `list(string)` | `[]` | ## Outputs @@ -337,51 +349,23 @@ module "api_service" { | ecs_instance_role_name | IAM role name for EC2 instances | | ecs_instance_security_group_id | Security group ID for EC2 instances | -### Public ALB - -| Name | Description | -|------|-------------| -| public_alb_arn | Public ALB ARN | -| public_alb_id | Public ALB ID | -| public_alb_dns_name | Public ALB DNS name | -| public_alb_zone_id | Public ALB hosted zone ID | -| public_alb_arn_suffix | Public ALB ARN suffix | -| public_alb_security_group_id | Public ALB security group ID | -| public_alb_http_listener_arn | Public ALB HTTP listener ARN | -| public_alb_https_listener_arn | Public ALB HTTPS listener ARN | - -### Private ALB +### Load balancers | Name | Description | |------|-------------| -| private_alb_arn | Private ALB ARN | -| private_alb_id | Private ALB ID | -| private_alb_dns_name | Private ALB DNS name | -| private_alb_zone_id | Private ALB hosted zone ID | -| private_alb_arn_suffix | Private ALB ARN suffix | -| private_alb_security_group_id | Private ALB security group ID | -| private_alb_http_listener_arn | Private ALB HTTP listener ARN | -| private_alb_https_listener_arn | Private ALB HTTPS listener ARN | +| public_albs | List of public ALB records (name, arn, id, dns_name, zone_id, arn_suffix, security_group_id, http_listener_arn, https_listener_arn, https_enabled) | +| private_albs | List of private ALB records (same shape as public_albs) | +| public_albs_by_name | Public ALB records keyed by name | +| private_albs_by_name | Private ALB records keyed by name | +| public_alb_options / private_alb_options | ALB names, for selection UIs | +| public_nlbs | List of public NLB records (name, arn, id, dns_name, zone_id, arn_suffix, security_group_id) | +| private_nlbs | List of private NLB records (same shape as public_nlbs) | +| public_nlbs_by_name / private_nlbs_by_name | NLB records keyed by name | +| public_nlb_options / private_nlb_options | NLB names, for selection UIs | -### Public NLB +### Deprecated singleton outputs -| Name | Description | -|------|-------------| -| public_nlb_arn | Public NLB ARN | -| public_nlb_id | Public NLB ID | -| public_nlb_dns_name | Public NLB DNS name | -| public_nlb_zone_id | Public NLB hosted zone ID | -| public_nlb_arn_suffix | Public NLB ARN suffix | - -### Private NLB - -| Name | Description | -|------|-------------| -| private_nlb_arn | Private NLB ARN | -| private_nlb_id | Private NLB ID | -| private_nlb_dns_name | Private NLB DNS name | -| private_nlb_zone_id | Private NLB hosted zone ID | -| private_nlb_arn_suffix | Private NLB ARN suffix | +The pre-2.0 singleton outputs (`public_alb_arn`, `public_alb_dns_name`, `public_alb_http_listener_arn`, `private_alb_arn`, `public_nlb_arn`, etc.) are still exposed for backward compatibility and return the first load balancer of the corresponding list, or `null` when the list is empty. ## Architecture @@ -472,8 +456,8 @@ module "api_service" { ║ ├────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────┤ ║ ║ │ PUBLIC ALB │ PRIVATE ALB │ ║ ║ ├────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤ ║ -║ │ • public_alb_enabled │ • private_alb_enabled │ ║ -║ │ • public_alb_https_enabled │ • private_alb_https_enabled │ ║ +║ │ • public_albs (array) │ • private_albs (array) │ ║ +║ │ • per-item https_enabled │ • per-item settings (below) │ ║ ║ │ • public_alb_certificate_arns │ • private_alb_certificate_arns │ ║ ║ │ • public_alb_ssl_policy │ • private_alb_ssl_policy │ ║ ║ │ • public_alb_idle_timeout │ • private_alb_idle_timeout │ ║ @@ -484,7 +468,7 @@ module "api_service" { ║ ├────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤ ║ ║ │ PUBLIC NLB │ PRIVATE NLB │ ║ ║ ├────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────┤ ║ -║ │ • public_nlb_enabled │ • private_nlb_enabled │ ║ +║ │ • public_nlbs (array) │ • private_nlbs (array) │ ║ ║ │ • public_nlb_enable_cross_zone_load_bal... │ • private_nlb_cross_zone_load_balancing_enabled │ ║ ║ │ • public_nlb_security_group_ids │ • private_nlb_security_group_ids │ ║ ║ │ • public_nlb_access_logs_enabled │ • private_nlb_access_logs_enabled │ ║ @@ -596,7 +580,7 @@ module "api_service" { ║ ├──────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ ║ ║ │ │ ║ ║ │ ┌────────────────────────────────┐ ┌────────────────────────────────┐ │ ║ -║ │ │ module.public_alb[0] │ │ module.private_alb[0] │ │ ║ +║ │ │ module.public_alb["name"] │ │ module.private_alb["name"] │ │ ║ ║ │ │ (networking/alb) │ │ (networking/alb) │ │ ║ ║ │ ├────────────────────────────────┤ ├────────────────────────────────┤ │ ║ ║ │ │ • Internet-facing │ │ • Internal │ │ ║ @@ -607,7 +591,7 @@ module "api_service" { ║ │ └────────────────────────────────┘ └────────────────────────────────┘ │ ║ ║ │ │ ║ ║ │ ┌────────────────────────────────┐ ┌────────────────────────────────┐ │ ║ -║ │ │ module.public_nlb[0] │ │ module.private_nlb[0] │ │ ║ +║ │ │ module.public_nlb["name"] │ │ module.private_nlb["name"] │ │ ║ ║ │ │ (networking/nlb) │ │ (networking/nlb) │ │ ║ ║ │ ├────────────────────────────────┤ ├────────────────────────────────┤ │ ║ ║ │ │ • Internet-facing │ │ • Internal │ │ ║ @@ -725,20 +709,20 @@ module "api_service" { ║ │ │ LOAD BALANCERS │ ║ ║ │ └────────────────────────────────────────────────────┘ ║ ║ │ ║ -║ │ var.public_alb_enabled ────►┌──────────────────────────┐ ║ -║ │ var.public_alb_* ─────────►│ module.public_alb │ ║ +║ │ length(var.public_albs) ───►┌──────────────────────────┐ ║ +║ │ var.public_albs[*] ───────►│ module.public_alb │ ║ ║ │ var.public_subnet_ids ────►└──────────────────────────┘ ║ ║ │ ║ -║ │ var.private_alb_enabled ───►┌──────────────────────────┐ ║ -║ │ var.private_alb_* ────────►│ module.private_alb │ ║ +║ │ length(var.private_albs) ──►┌──────────────────────────┐ ║ +║ │ var.private_albs[*] ──────►│ module.private_alb │ ║ ║ └──────────────────────────────►└──────────────────────────┘ ║ ║ │ ║ -║ │ var.public_nlb_enabled ────►┌──────────────────────────┐ ║ -║ │ var.public_nlb_* ─────────►│ module.public_nlb │ ║ +║ │ length(var.public_nlbs) ───►┌──────────────────────────┐ ║ +║ │ var.public_nlbs[*] ───────►│ module.public_nlb │ ║ ║ │ var.public_subnet_ids ────►└──────────────────────────┘ ║ ║ │ ║ -║ │ var.private_nlb_enabled ───►┌──────────────────────────┐ ║ -║ │ var.private_nlb_* ────────►│ module.private_nlb │ ║ +║ │ length(var.private_nlbs) ──►┌──────────────────────────┐ ║ +║ │ var.private_nlbs[*] ──────►│ module.private_nlb │ ║ ║ └──────────────────────────────►└──────────────────────────┘ ║ ║ ║ ║ │ ║ @@ -760,10 +744,10 @@ module "api_service" { | `aws_iam_role_policy_attachment` | 0 or 2 | ECS and SSM policy attachments | | `module.ecs_autoscaling` | 0 or 1 | Auto Scaling Group for EC2 instances | | `module.ecs_instance_security_group` | 0 or 1 | Security group for EC2 instances | -| `module.public_alb` | 0 or 1 | Public Application Load Balancer | -| `module.private_alb` | 0 or 1 | Private Application Load Balancer | -| `module.public_nlb` | 0 or 1 | Public Network Load Balancer | -| `module.private_nlb` | 0 or 1 | Private Network Load Balancer | +| `module.public_alb` | 0 to N (one per entry, keyed by name) | Public Application Load Balancers | +| `module.private_alb` | 0 to N (one per entry, keyed by name) | Private Application Load Balancers | +| `module.public_nlb` | 0 to N (one per entry, keyed by name) | Public Network Load Balancers | +| `module.private_nlb` | 0 to N (one per entry, keyed by name) | Private Network Load Balancers | ## FAQ @@ -895,14 +879,22 @@ module "ecs" { source = "..." # ALB for HTTP/HTTPS traffic with path-based routing - public_alb_enabled = true - public_alb_https_enabled = true - public_alb_certificate_arns = ["arn:aws:acm:..."] + public_albs = [ + { + name = "my-app-pub" + https_enabled = true + certificate_arns = ["arn:aws:acm:..."] + } + ] # NLB for TCP/UDP traffic or static IPs - public_nlb_enabled = true - public_nlb_elastic_ips_enabled = true - public_nlb_elastic_ip_allocation_ids = ["eipalloc-abc123", "eipalloc-def456"] + public_nlbs = [ + { + name = "my-app-pub-nlb" + elastic_ips_enabled = true + elastic_ip_allocation_ids = ["eipalloc-abc123", "eipalloc-def456"] + } + ] } ``` @@ -962,8 +954,8 @@ The module automatically creates a security group for EC2 instances that: │ │ Egress: 0.0.0.0/0 (all traffic) │ │ │ │ │ │ │ │ Ingress (dynamic): │ │ -│ │ ├─ From Public ALB SG (if public_alb_enabled = true) │ │ -│ │ └─ From Private ALB SG (if private_alb_enabled = true) │ │ +│ │ ├─ From each public ALB security group │ │ +│ │ └─ From each private ALB security group │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ Additional security groups can be attached via: │ @@ -986,3 +978,20 @@ The module automatically creates a security group for EC2 instances that: - The `name` variable is limited to 28 characters to ensure ALB names don't exceed AWS limits - EBS volumes on EC2 instances are encrypted by default - Managed termination protection prevents ECS from terminating instances with running tasks + +## Migrating from 1.x (single load balancer inputs) + +Version 2.0.0 replaces the singleton `public_alb_*`/`private_alb_*`/`*_nlb_*` variables with the +`public_albs`, `private_albs`, `public_nlbs`, and `private_nlbs` object arrays. Load balancer module +instances are keyed by their resolved name (`for_each`) instead of a positional index (`count`), so +Terraform addresses change from `module.public_alb[0]` to `module.public_alb[""]`. + +For existing state, move each load balancer to its new address before applying, e.g.: + +```bash +tofu state mv 'module.public_alb[0]' 'module.public_alb["my-cluster-pub"]' +``` + +The default resolved name for the first entry is `-pub` (public ALB), `-priv` +(private ALB), `-pub-nlb` (public NLB), and `-priv-nlb` (private NLB) when no explicit +`name` is set. Without the state move, Terraform plans to destroy and recreate the load balancer. diff --git a/compute/ecs_cluster/ec2.tf b/compute/ecs_cluster/ec2.tf index c4acd1d7..4c777fd5 100644 --- a/compute/ecs_cluster/ec2.tf +++ b/compute/ecs_cluster/ec2.tf @@ -66,26 +66,22 @@ module "ecs_instance_security_group" { # For ip_protocol="-1" (all protocols), AWS requires from_port/to_port to # be -1; setting them to 0 causes update failures. ingress_rules = concat( - # Allow inbound from public ALB if enabled - var.public_alb_enabled ? [ - { - description = "Allow inbound from public ALB" - from_port = -1 - to_port = -1 - ip_protocol = "-1" - referenced_security_group_id = module.public_alb[0].security_group_id - } - ] : [], - # Allow inbound from private ALB if enabled - var.private_alb_enabled ? [ - { - description = "Allow inbound from private ALB" - from_port = -1 - to_port = -1 - ip_protocol = "-1" - referenced_security_group_id = module.private_alb[0].security_group_id - } - ] : [] + # Allow inbound from each public ALB + [for name, lb in module.public_alb : { + description = "Allow inbound from public ALB ${name}" + from_port = -1 + to_port = -1 + ip_protocol = "-1" + referenced_security_group_id = lb.security_group_id + }], + # Allow inbound from each private ALB + [for name, lb in module.private_alb : { + description = "Allow inbound from private ALB ${name}" + from_port = -1 + to_port = -1 + ip_protocol = "-1" + referenced_security_group_id = lb.security_group_id + }] ) } diff --git a/compute/ecs_cluster/load_balancers.tf b/compute/ecs_cluster/load_balancers.tf index 5ff09c5b..b8c02e58 100644 --- a/compute/ecs_cluster/load_balancers.tf +++ b/compute/ecs_cluster/load_balancers.tf @@ -1,13 +1,13 @@ ################################################################################ -# Public Application Load Balancer +# Public Application Load Balancers ################################################################################ module "public_alb" { - count = var.public_alb_enabled ? 1 : 0 + for_each = local.public_albs_by_name source = "../../networking/alb" - name = "${var.name}-pub" + name = each.key tags = var.tags vpc_id = var.vpc_id @@ -16,40 +16,40 @@ module "public_alb" { # Listener configuration http_listener_enabled = true - https_listener_enabled = var.public_alb_https_enabled - http_to_https_redirect_enabled = var.public_alb_https_enabled + https_listener_enabled = each.value.https_enabled + http_to_https_redirect_enabled = each.value.https_enabled # SSL/TLS - certificate_arns = var.public_alb_certificate_arns - ssl_policy = var.public_alb_ssl_policy + certificate_arns = each.value.certificate_arns + ssl_policy = each.value.ssl_policy # ALB settings - idle_timeout = var.public_alb_idle_timeout + idle_timeout = each.value.idle_timeout deletion_protection_enabled = var.load_balancer_deletion_protection_enabled # Security - ingress_cidr_blocks = var.public_alb_ingress_cidr_blocks - ingress_ipv6_cidr_blocks = var.public_alb_ingress_ipv6_cidr_blocks - ingress_security_group_ids = var.public_alb_ingress_security_group_ids + ingress_cidr_blocks = each.value.ingress_cidr_blocks + ingress_ipv6_cidr_blocks = each.value.ingress_ipv6_cidr_blocks + ingress_security_group_ids = each.value.ingress_security_group_ids # Access logs - access_logs_enabled = var.public_alb_access_logs_enabled - access_logs_bucket_arn = var.public_alb_access_logs_bucket_arn + access_logs_enabled = each.value.access_logs_enabled + access_logs_bucket_arn = each.value.access_logs_bucket_arn # WAF - web_acl_arn = var.public_alb_web_acl_arn + web_acl_arn = each.value.web_acl_arn } ################################################################################ -# Private Application Load Balancer +# Private Application Load Balancers ################################################################################ module "private_alb" { - count = var.private_alb_enabled ? 1 : 0 + for_each = local.private_albs_by_name source = "../../networking/alb" - name = "${var.name}-priv" + name = each.key tags = var.tags vpc_id = var.vpc_id @@ -58,37 +58,37 @@ module "private_alb" { # Listener configuration http_listener_enabled = true - https_listener_enabled = var.private_alb_https_enabled - http_to_https_redirect_enabled = var.private_alb_https_enabled + https_listener_enabled = each.value.https_enabled + http_to_https_redirect_enabled = each.value.https_enabled # SSL/TLS - certificate_arns = var.private_alb_certificate_arns - ssl_policy = var.private_alb_ssl_policy + certificate_arns = each.value.certificate_arns + ssl_policy = each.value.ssl_policy # ALB settings - idle_timeout = var.private_alb_idle_timeout + idle_timeout = each.value.idle_timeout deletion_protection_enabled = var.load_balancer_deletion_protection_enabled # Security - ingress_cidr_blocks = var.private_alb_ingress_cidr_blocks - ingress_ipv6_cidr_blocks = var.private_alb_ingress_ipv6_cidr_blocks - ingress_security_group_ids = var.private_alb_ingress_security_group_ids + ingress_cidr_blocks = each.value.ingress_cidr_blocks + ingress_ipv6_cidr_blocks = each.value.ingress_ipv6_cidr_blocks + ingress_security_group_ids = each.value.ingress_security_group_ids # Access logs - access_logs_enabled = var.private_alb_access_logs_enabled - access_logs_bucket_arn = var.private_alb_access_logs_bucket_arn + access_logs_enabled = each.value.access_logs_enabled + access_logs_bucket_arn = each.value.access_logs_bucket_arn } ################################################################################ -# Public Network Load Balancer +# Public Network Load Balancers ################################################################################ module "public_nlb" { - count = var.public_nlb_enabled ? 1 : 0 + for_each = local.public_nlbs_by_name source = "../../networking/nlb" - name = "${var.name}-pub-nlb" + name = each.key tags = var.tags vpc_id = var.vpc_id @@ -97,30 +97,30 @@ module "public_nlb" { # NLB settings deletion_protection_enabled = var.load_balancer_deletion_protection_enabled - cross_zone_load_balancing_enabled = var.public_nlb_cross_zone_load_balancing_enabled + cross_zone_load_balancing_enabled = each.value.cross_zone_load_balancing_enabled # Security groups - additional_security_group_ids = var.public_nlb_security_group_ids + additional_security_group_ids = each.value.security_group_ids # Access logs - access_logs_enabled = var.public_nlb_access_logs_enabled - access_logs_bucket_arn = var.public_nlb_access_logs_bucket_arn + access_logs_enabled = each.value.access_logs_enabled + access_logs_bucket_arn = each.value.access_logs_bucket_arn # Elastic IPs - elastic_ips_enabled = var.public_nlb_elastic_ips_enabled - elastic_ip_allocation_ids = var.public_nlb_elastic_ip_allocation_ids + elastic_ips_enabled = each.value.elastic_ips_enabled + elastic_ip_allocation_ids = each.value.elastic_ip_allocation_ids } ################################################################################ -# Private Network Load Balancer +# Private Network Load Balancers ################################################################################ module "private_nlb" { - count = var.private_nlb_enabled ? 1 : 0 + for_each = local.private_nlbs_by_name source = "../../networking/nlb" - name = "${var.name}-priv-nlb" + name = each.key tags = var.tags vpc_id = var.vpc_id @@ -129,16 +129,16 @@ module "private_nlb" { # NLB settings deletion_protection_enabled = var.load_balancer_deletion_protection_enabled - cross_zone_load_balancing_enabled = var.private_nlb_cross_zone_load_balancing_enabled + cross_zone_load_balancing_enabled = each.value.cross_zone_load_balancing_enabled # Security groups - additional_security_group_ids = var.private_nlb_security_group_ids + additional_security_group_ids = each.value.security_group_ids # Access logs - access_logs_enabled = var.private_nlb_access_logs_enabled - access_logs_bucket_arn = var.private_nlb_access_logs_bucket_arn + access_logs_enabled = each.value.access_logs_enabled + access_logs_bucket_arn = each.value.access_logs_bucket_arn # Elastic IPs - elastic_ips_enabled = var.private_nlb_elastic_ips_enabled - elastic_ip_allocation_ids = var.private_nlb_elastic_ip_allocation_ids + elastic_ips_enabled = each.value.elastic_ips_enabled + elastic_ip_allocation_ids = each.value.elastic_ip_allocation_ids } diff --git a/compute/ecs_cluster/locals.tf b/compute/ecs_cluster/locals.tf index 1b1ed3db..faf1f843 100644 --- a/compute/ecs_cluster/locals.tf +++ b/compute/ecs_cluster/locals.tf @@ -66,3 +66,21 @@ locals { var.ec2_spot_instance_types ) : [] } + +################################################################################ +# Load Balancer Names +################################################################################ + +locals { + public_alb_names = [for idx, lb in var.public_albs : coalesce(lb.name, idx == 0 ? "${var.name}-pub" : "${var.name}-pub-${idx + 1}")] + private_alb_names = [for idx, lb in var.private_albs : coalesce(lb.name, idx == 0 ? "${var.name}-priv" : "${var.name}-priv-${idx + 1}")] + public_nlb_names = [for idx, lb in var.public_nlbs : coalesce(lb.name, idx == 0 ? "${var.name}-pub-nlb" : "${var.name}-pub-nlb-${idx + 1}")] + private_nlb_names = [for idx, lb in var.private_nlbs : coalesce(lb.name, idx == 0 ? "${var.name}-priv-nlb" : "${var.name}-priv-nlb-${idx + 1}")] + + # Load balancer configs keyed by resolved name so Terraform addresses stay + # stable when entries are inserted, removed, or reordered. + public_albs_by_name = { for idx, lb in var.public_albs : local.public_alb_names[idx] => lb } + private_albs_by_name = { for idx, lb in var.private_albs : local.private_alb_names[idx] => lb } + public_nlbs_by_name = { for idx, lb in var.public_nlbs : local.public_nlb_names[idx] => lb } + private_nlbs_by_name = { for idx, lb in var.private_nlbs : local.private_nlb_names[idx] => lb } +} diff --git a/compute/ecs_cluster/outputs.tf b/compute/ecs_cluster/outputs.tf index 70a7224c..3cd8e85a 100644 --- a/compute/ecs_cluster/outputs.tf +++ b/compute/ecs_cluster/outputs.tf @@ -81,159 +81,307 @@ output "ecs_instance_security_group_id" { } ################################################################################ -# Public ALB +# Public ALBs ################################################################################ +output "public_albs" { + description = "All public ALBs with their attributes, in the same order as var.public_albs." + value = [for name in local.public_alb_names : { + name = name + arn = module.public_alb[name].alb_arn + id = module.public_alb[name].alb_id + dns_name = module.public_alb[name].alb_dns_name + zone_id = module.public_alb[name].alb_zone_id + arn_suffix = module.public_alb[name].alb_arn_suffix + security_group_id = module.public_alb[name].security_group_id + http_listener_arn = module.public_alb[name].http_listener_arn + https_listener_arn = local.public_albs_by_name[name].https_enabled ? module.public_alb[name].https_listener_arn : null + https_enabled = local.public_albs_by_name[name].https_enabled + }] +} + +output "public_albs_by_name" { + description = "Public ALB attributes keyed by load balancer name." + value = { for name in local.public_alb_names : name => { + name = name + arn = module.public_alb[name].alb_arn + id = module.public_alb[name].alb_id + dns_name = module.public_alb[name].alb_dns_name + zone_id = module.public_alb[name].alb_zone_id + arn_suffix = module.public_alb[name].alb_arn_suffix + security_group_id = module.public_alb[name].security_group_id + http_listener_arn = module.public_alb[name].http_listener_arn + https_listener_arn = local.public_albs_by_name[name].https_enabled ? module.public_alb[name].https_listener_arn : null + https_enabled = local.public_albs_by_name[name].https_enabled + } } +} + +output "public_alb_options" { + description = "Public ALB select options ({label, value} pairs of load balancer names) for module definition forms." + value = [for name in local.public_alb_names : { label = name, value = name }] +} + +# Deprecated single-ALB outputs (first entry of public_albs); kept for +# consumers that support only one load balancer per type. + output "public_alb_arn" { - description = "The ARN of the public ALB (null if disabled)." - value = var.public_alb_enabled ? module.public_alb[0].alb_arn : null + description = "The ARN of the first public ALB (null if none)." + value = length(local.public_alb_names) > 0 ? module.public_alb[local.public_alb_names[0]].alb_arn : null } output "public_alb_id" { - description = "The ID of the public ALB (null if disabled)." - value = var.public_alb_enabled ? module.public_alb[0].alb_id : null + description = "The ID of the first public ALB (null if none)." + value = length(local.public_alb_names) > 0 ? module.public_alb[local.public_alb_names[0]].alb_id : null } output "public_alb_dns_name" { - description = "The DNS name of the public ALB (null if disabled)." - value = var.public_alb_enabled ? module.public_alb[0].alb_dns_name : null + description = "The DNS name of the first public ALB (null if none)." + value = length(local.public_alb_names) > 0 ? module.public_alb[local.public_alb_names[0]].alb_dns_name : null } output "public_alb_zone_id" { - description = "The canonical hosted zone ID of the public ALB (null if disabled)." - value = var.public_alb_enabled ? module.public_alb[0].alb_zone_id : null + description = "The canonical hosted zone ID of the first public ALB (null if none)." + value = length(local.public_alb_names) > 0 ? module.public_alb[local.public_alb_names[0]].alb_zone_id : null } output "public_alb_arn_suffix" { - description = "The ARN suffix of the public ALB for CloudWatch Metrics (null if disabled)." - value = var.public_alb_enabled ? module.public_alb[0].alb_arn_suffix : null + description = "The ARN suffix of the first public ALB for CloudWatch Metrics (null if none)." + value = length(local.public_alb_names) > 0 ? module.public_alb[local.public_alb_names[0]].alb_arn_suffix : null } output "public_alb_security_group_id" { - description = "The ID of the public ALB security group (null if disabled)." - value = var.public_alb_enabled ? module.public_alb[0].security_group_id : null + description = "The ID of the first public ALB security group (null if none)." + value = length(local.public_alb_names) > 0 ? module.public_alb[local.public_alb_names[0]].security_group_id : null } output "public_alb_http_listener_arn" { - description = "The ARN of the public ALB HTTP listener (null if disabled)." - value = var.public_alb_enabled ? module.public_alb[0].http_listener_arn : null + description = "The ARN of the first public ALB HTTP listener (null if none)." + value = length(local.public_alb_names) > 0 ? module.public_alb[local.public_alb_names[0]].http_listener_arn : null } output "public_alb_https_listener_arn" { - description = "The ARN of the public ALB HTTPS listener (null if HTTPS disabled)." - value = var.public_alb_enabled && var.public_alb_https_enabled ? module.public_alb[0].https_listener_arn : null + description = "The ARN of the first public ALB HTTPS listener (null if HTTPS disabled)." + value = length(local.public_alb_names) > 0 && var.public_albs[0].https_enabled ? module.public_alb[local.public_alb_names[0]].https_listener_arn : null } ################################################################################ -# Private ALB +# Private ALBs ################################################################################ +output "private_albs" { + description = "All private ALBs with their attributes, in the same order as var.private_albs." + value = [for name in local.private_alb_names : { + name = name + arn = module.private_alb[name].alb_arn + id = module.private_alb[name].alb_id + dns_name = module.private_alb[name].alb_dns_name + zone_id = module.private_alb[name].alb_zone_id + arn_suffix = module.private_alb[name].alb_arn_suffix + security_group_id = module.private_alb[name].security_group_id + http_listener_arn = module.private_alb[name].http_listener_arn + https_listener_arn = local.private_albs_by_name[name].https_enabled ? module.private_alb[name].https_listener_arn : null + https_enabled = local.private_albs_by_name[name].https_enabled + }] +} + +output "private_albs_by_name" { + description = "Private ALB attributes keyed by load balancer name." + value = { for name in local.private_alb_names : name => { + name = name + arn = module.private_alb[name].alb_arn + id = module.private_alb[name].alb_id + dns_name = module.private_alb[name].alb_dns_name + zone_id = module.private_alb[name].alb_zone_id + arn_suffix = module.private_alb[name].alb_arn_suffix + security_group_id = module.private_alb[name].security_group_id + http_listener_arn = module.private_alb[name].http_listener_arn + https_listener_arn = local.private_albs_by_name[name].https_enabled ? module.private_alb[name].https_listener_arn : null + https_enabled = local.private_albs_by_name[name].https_enabled + } } +} + +output "private_alb_options" { + description = "Private ALB select options ({label, value} pairs of load balancer names) for module definition forms." + value = [for name in local.private_alb_names : { label = name, value = name }] +} + +# Deprecated single-ALB outputs (first entry of private_albs); kept for +# consumers that support only one load balancer per type. + output "private_alb_arn" { - description = "The ARN of the private ALB (null if disabled)." - value = var.private_alb_enabled ? module.private_alb[0].alb_arn : null + description = "The ARN of the first private ALB (null if none)." + value = length(local.private_alb_names) > 0 ? module.private_alb[local.private_alb_names[0]].alb_arn : null } output "private_alb_id" { - description = "The ID of the private ALB (null if disabled)." - value = var.private_alb_enabled ? module.private_alb[0].alb_id : null + description = "The ID of the first private ALB (null if none)." + value = length(local.private_alb_names) > 0 ? module.private_alb[local.private_alb_names[0]].alb_id : null } output "private_alb_dns_name" { - description = "The DNS name of the private ALB (null if disabled)." - value = var.private_alb_enabled ? module.private_alb[0].alb_dns_name : null + description = "The DNS name of the first private ALB (null if none)." + value = length(local.private_alb_names) > 0 ? module.private_alb[local.private_alb_names[0]].alb_dns_name : null } output "private_alb_zone_id" { - description = "The canonical hosted zone ID of the private ALB (null if disabled)." - value = var.private_alb_enabled ? module.private_alb[0].alb_zone_id : null + description = "The canonical hosted zone ID of the first private ALB (null if none)." + value = length(local.private_alb_names) > 0 ? module.private_alb[local.private_alb_names[0]].alb_zone_id : null } output "private_alb_arn_suffix" { - description = "The ARN suffix of the private ALB for CloudWatch Metrics (null if disabled)." - value = var.private_alb_enabled ? module.private_alb[0].alb_arn_suffix : null + description = "The ARN suffix of the first private ALB for CloudWatch Metrics (null if none)." + value = length(local.private_alb_names) > 0 ? module.private_alb[local.private_alb_names[0]].alb_arn_suffix : null } output "private_alb_security_group_id" { - description = "The ID of the private ALB security group (null if disabled)." - value = var.private_alb_enabled ? module.private_alb[0].security_group_id : null + description = "The ID of the first private ALB security group (null if none)." + value = length(local.private_alb_names) > 0 ? module.private_alb[local.private_alb_names[0]].security_group_id : null } output "private_alb_http_listener_arn" { - description = "The ARN of the private ALB HTTP listener (null if disabled)." - value = var.private_alb_enabled ? module.private_alb[0].http_listener_arn : null + description = "The ARN of the first private ALB HTTP listener (null if none)." + value = length(local.private_alb_names) > 0 ? module.private_alb[local.private_alb_names[0]].http_listener_arn : null } output "private_alb_https_listener_arn" { - description = "The ARN of the private ALB HTTPS listener (null if HTTPS disabled)." - value = var.private_alb_enabled && var.private_alb_https_enabled ? module.private_alb[0].https_listener_arn : null + description = "The ARN of the first private ALB HTTPS listener (null if HTTPS disabled)." + value = length(local.private_alb_names) > 0 && var.private_albs[0].https_enabled ? module.private_alb[local.private_alb_names[0]].https_listener_arn : null } ################################################################################ -# Public NLB +# Public NLBs ################################################################################ +output "public_nlbs" { + description = "All public NLBs with their attributes, in the same order as var.public_nlbs." + value = [for name in local.public_nlb_names : { + name = name + arn = module.public_nlb[name].nlb_arn + id = module.public_nlb[name].nlb_id + dns_name = module.public_nlb[name].nlb_dns_name + zone_id = module.public_nlb[name].nlb_zone_id + arn_suffix = module.public_nlb[name].nlb_arn_suffix + security_group_id = module.public_nlb[name].security_group_id + }] +} + +output "public_nlbs_by_name" { + description = "Public NLB attributes keyed by load balancer name." + value = { for name in local.public_nlb_names : name => { + name = name + arn = module.public_nlb[name].nlb_arn + id = module.public_nlb[name].nlb_id + dns_name = module.public_nlb[name].nlb_dns_name + zone_id = module.public_nlb[name].nlb_zone_id + arn_suffix = module.public_nlb[name].nlb_arn_suffix + security_group_id = module.public_nlb[name].security_group_id + } } +} + +output "public_nlb_options" { + description = "Public NLB select options ({label, value} pairs of load balancer names) for module definition forms." + value = [for name in local.public_nlb_names : { label = name, value = name }] +} + +# Deprecated single-NLB outputs (first entry of public_nlbs); kept for +# consumers that support only one load balancer per type. + output "public_nlb_arn" { - description = "The ARN of the public NLB (null if disabled)." - value = var.public_nlb_enabled ? module.public_nlb[0].nlb_arn : null + description = "The ARN of the first public NLB (null if none)." + value = length(local.public_nlb_names) > 0 ? module.public_nlb[local.public_nlb_names[0]].nlb_arn : null } output "public_nlb_id" { - description = "The ID of the public NLB (null if disabled)." - value = var.public_nlb_enabled ? module.public_nlb[0].nlb_id : null + description = "The ID of the first public NLB (null if none)." + value = length(local.public_nlb_names) > 0 ? module.public_nlb[local.public_nlb_names[0]].nlb_id : null } output "public_nlb_dns_name" { - description = "The DNS name of the public NLB (null if disabled)." - value = var.public_nlb_enabled ? module.public_nlb[0].nlb_dns_name : null + description = "The DNS name of the first public NLB (null if none)." + value = length(local.public_nlb_names) > 0 ? module.public_nlb[local.public_nlb_names[0]].nlb_dns_name : null } output "public_nlb_zone_id" { - description = "The canonical hosted zone ID of the public NLB (null if disabled)." - value = var.public_nlb_enabled ? module.public_nlb[0].nlb_zone_id : null + description = "The canonical hosted zone ID of the first public NLB (null if none)." + value = length(local.public_nlb_names) > 0 ? module.public_nlb[local.public_nlb_names[0]].nlb_zone_id : null } output "public_nlb_arn_suffix" { - description = "The ARN suffix of the public NLB for CloudWatch Metrics (null if disabled)." - value = var.public_nlb_enabled ? module.public_nlb[0].nlb_arn_suffix : null + description = "The ARN suffix of the first public NLB for CloudWatch Metrics (null if none)." + value = length(local.public_nlb_names) > 0 ? module.public_nlb[local.public_nlb_names[0]].nlb_arn_suffix : null } output "public_nlb_security_group_id" { - description = "The ID of the public NLB security group (null if disabled)." - value = var.public_nlb_enabled ? module.public_nlb[0].security_group_id : null + description = "The ID of the first public NLB security group (null if none)." + value = length(local.public_nlb_names) > 0 ? module.public_nlb[local.public_nlb_names[0]].security_group_id : null } ################################################################################ -# Private NLB +# Private NLBs ################################################################################ +output "private_nlbs" { + description = "All private NLBs with their attributes, in the same order as var.private_nlbs." + value = [for name in local.private_nlb_names : { + name = name + arn = module.private_nlb[name].nlb_arn + id = module.private_nlb[name].nlb_id + dns_name = module.private_nlb[name].nlb_dns_name + zone_id = module.private_nlb[name].nlb_zone_id + arn_suffix = module.private_nlb[name].nlb_arn_suffix + security_group_id = module.private_nlb[name].security_group_id + }] +} + +output "private_nlbs_by_name" { + description = "Private NLB attributes keyed by load balancer name." + value = { for name in local.private_nlb_names : name => { + name = name + arn = module.private_nlb[name].nlb_arn + id = module.private_nlb[name].nlb_id + dns_name = module.private_nlb[name].nlb_dns_name + zone_id = module.private_nlb[name].nlb_zone_id + arn_suffix = module.private_nlb[name].nlb_arn_suffix + security_group_id = module.private_nlb[name].security_group_id + } } +} + +output "private_nlb_options" { + description = "Private NLB select options ({label, value} pairs of load balancer names) for module definition forms." + value = [for name in local.private_nlb_names : { label = name, value = name }] +} + +# Deprecated single-NLB outputs (first entry of private_nlbs); kept for +# consumers that support only one load balancer per type. + output "private_nlb_arn" { - description = "The ARN of the private NLB (null if disabled)." - value = var.private_nlb_enabled ? module.private_nlb[0].nlb_arn : null + description = "The ARN of the first private NLB (null if none)." + value = length(local.private_nlb_names) > 0 ? module.private_nlb[local.private_nlb_names[0]].nlb_arn : null } output "private_nlb_id" { - description = "The ID of the private NLB (null if disabled)." - value = var.private_nlb_enabled ? module.private_nlb[0].nlb_id : null + description = "The ID of the first private NLB (null if none)." + value = length(local.private_nlb_names) > 0 ? module.private_nlb[local.private_nlb_names[0]].nlb_id : null } output "private_nlb_dns_name" { - description = "The DNS name of the private NLB (null if disabled)." - value = var.private_nlb_enabled ? module.private_nlb[0].nlb_dns_name : null + description = "The DNS name of the first private NLB (null if none)." + value = length(local.private_nlb_names) > 0 ? module.private_nlb[local.private_nlb_names[0]].nlb_dns_name : null } output "private_nlb_zone_id" { - description = "The canonical hosted zone ID of the private NLB (null if disabled)." - value = var.private_nlb_enabled ? module.private_nlb[0].nlb_zone_id : null + description = "The canonical hosted zone ID of the first private NLB (null if none)." + value = length(local.private_nlb_names) > 0 ? module.private_nlb[local.private_nlb_names[0]].nlb_zone_id : null } output "private_nlb_arn_suffix" { - description = "The ARN suffix of the private NLB for CloudWatch Metrics (null if disabled)." - value = var.private_nlb_enabled ? module.private_nlb[0].nlb_arn_suffix : null + description = "The ARN suffix of the first private NLB for CloudWatch Metrics (null if none)." + value = length(local.private_nlb_names) > 0 ? module.private_nlb[local.private_nlb_names[0]].nlb_arn_suffix : null } output "private_nlb_security_group_id" { - description = "The ID of the private NLB security group (null if disabled)." - value = var.private_nlb_enabled ? module.private_nlb[0].security_group_id : null + description = "The ID of the first private NLB security group (null if none)." + value = length(local.private_nlb_names) > 0 ? module.private_nlb[local.private_nlb_names[0]].security_group_id : null } diff --git a/compute/ecs_cluster/rvn-ecs-cluster-definition.yml b/compute/ecs_cluster/rvn-ecs-cluster-definition.yml index 585c955f..01074030 100644 --- a/compute/ecs_cluster/rvn-ecs-cluster-definition.yml +++ b/compute/ecs_cluster/rvn-ecs-cluster-definition.yml @@ -3,8 +3,11 @@ definition: name: ECS Cluster description: Production-ready AWS ECS cluster with Fargate, Fargate Spot, optional EC2 capacity, and shared load balancers. release: - version: 1.0.0 - description: First stable release. + version: 2.0.0 + description: >- + Support multiple public and private load balancers per cluster. Public/private + ALBs and NLBs are now configured as arrays with per-load-balancer settings, and + cluster outputs expose the full load balancer lists so services can pick one by name. module: inputs: - id: network @@ -219,283 +222,310 @@ module: not: "" ec2_managed_scaling_enabled: true type: number - - description: Creates an internet-facing Application Load Balancer for services that should receive traffic from the public internet, such as websites, public APIs, and webhook endpoints. - id: section_public_alb - label: Public application load balancer + - description: Internet-facing Application Load Balancers for services that should receive traffic from the public internet, such as websites, public APIs, and webhook endpoints. + id: section_public_albs + label: Public application load balancers type: section - - default: true - id: public_alb_enabled - label: Public load balancer - type: boolean - - default: true - description: Enable HTTPS listener on the public ALB. - id: public_alb_https_enabled - label: HTTPS - show_when: - public_alb_enabled: true - type: boolean - - $template: ../../partials/templates/alb-certificate-input.yml - with: - additional_certificate_arns_description: Additional ACM certificate ARNs attached to the public ALB HTTPS listener for SNI. - additional_certificate_arns_input_id: public_alb_additional_certificate_arns - certificate_arns_description: ACM certificate ARNs resolved from the selected module. The first ARN is the default certificate. - certificate_arns_input_id: public_alb_certificate_arns - certificate_description: Primary ACM certificate module for public ALB HTTPS. - certificate_input_id: public_alb_certificate - show_when: - public_alb_enabled: true - public_alb_https_enabled: true - - collapsible: true - description: SSL policy for public ALB HTTPS. - id: public_alb_ssl_policy - label: SSL policy - placeholder: ELBSecurityPolicy-TLS13-1-2-2021-06 - show_when: - public_alb_enabled: true - public_alb_https_enabled: true - type: string - - collapsible: true - description: Idle timeout for the public ALB. - id: public_alb_idle_timeout - label: Idle timeout (seconds) - max: 4000 - min: 1 - placeholder: "60" - show_when: - public_alb_enabled: true - type: number - - collapsible: true - description: WAFv2 Web ACL ARN for the public ALB. - id: public_alb_web_acl_arn - label: WAF web ACL ARN - placeholder: arn:aws:wafv2:... - show_when: - public_alb_enabled: true - type: string - - collapsible: true - default: false - description: Enable public ALB access logging. - id: public_alb_access_logs_enabled - label: Access logs - show_when: - public_alb_enabled: true - type: boolean - - collapsible: true - description: Existing S3 bucket ARN for public ALB access logs. - id: public_alb_access_logs_bucket_arn - label: Access logs bucket ARN - placeholder: arn:aws:s3:::my-bucket - show_when: - public_alb_enabled: true - public_alb_access_logs_enabled: true - type: string - - description: Creates an internal Application Load Balancer for services that should only be reachable inside the VPC or private network, such as internal APIs, admin tools, and service-to-service traffic. - id: section_private_alb - label: Private application load balancer + - label: Public load balancers + default: + - name: <>-<>-pub + https_enabled: true + description: Each entry creates one internet-facing Application Load Balancer. Services on this cluster choose a load balancer by name. + id: public_albs + item_label: Public load balancer + item_title: + template: "{name}" + item_inputs: + - description: Unique load balancer name. + id: name + label: Name + patterns: + - message: The name must be 1-32 characters, contain only letters, numbers, and hyphens, and start and end with a letter or number. + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,30}[a-zA-Z0-9])?$ + required: true + type: string + - default: true + description: Enable an HTTPS listener on this load balancer. + id: https_enabled + label: HTTPS + type: boolean + - $template: ../../partials/templates/alb-certificate-input.yml + with: + additional_certificate_arns_description: Additional ACM certificate ARNs attached to the HTTPS listener for SNI. + additional_certificate_arns_input_id: additional_certificate_arns + certificate_arns_description: ACM certificate ARNs resolved from the selected module. The first ARN is the default certificate. + certificate_arns_input_id: certificate_arns + certificate_description: Primary ACM certificate module for HTTPS. + certificate_input_id: certificate + show_when: + https_enabled: true + - collapsible: true + description: SSL policy for the HTTPS listener. + id: ssl_policy + label: SSL policy + placeholder: ELBSecurityPolicy-TLS13-1-2-2021-06 + show_when: + https_enabled: true + type: string + - add_button_label: Add CIDR block + collapsible: true + description: Allowed IPv4 CIDRs. Defaults to allowing all IPv4 traffic. + id: ingress_cidr_blocks + label: Ingress CIDRs + placeholder: 0.0.0.0/0 + type: string_array + - add_button_label: Add IPv6 CIDR block + collapsible: true + description: Allowed IPv6 CIDRs. Defaults to allowing all IPv6 traffic. + id: ingress_ipv6_cidr_blocks + label: Ingress IPv6 CIDRs + placeholder: ::/0 + type: string_array + - add_button_label: Add security group + collapsible: true + description: Security groups whose members can access this load balancer. Useful for sources without static CIDRs, such as CloudFront VPC origins. + id: ingress_security_group_ids + label: Ingress security groups + placeholder: sg-... + type: string_array + - collapsible: true + description: Idle timeout for this load balancer. + id: idle_timeout + label: Idle timeout (seconds) + max: 4000 + min: 1 + placeholder: "60" + type: number + - collapsible: true + description: WAFv2 Web ACL ARN for this load balancer. + id: web_acl_arn + label: WAF web ACL ARN + placeholder: arn:aws:wafv2:... + type: string + - collapsible: true + default: false + description: Enable access logging for this load balancer. + id: access_logs_enabled + label: Access logs + type: boolean + - collapsible: true + description: Existing S3 bucket ARN for access logs. + id: access_logs_bucket_arn + label: Access logs bucket ARN + placeholder: arn:aws:s3:::my-bucket + show_when: + access_logs_enabled: true + type: string + type: object_array + - description: Internal Application Load Balancers for services that should only be reachable inside the VPC or private network, such as internal APIs, admin tools, and service-to-service traffic. + id: section_private_albs + label: Private application load balancers type: section - - default: false - id: private_alb_enabled - label: Private load balancer - type: boolean - - default: false - description: Enable HTTPS listener on the private ALB. - id: private_alb_https_enabled - label: HTTPS - show_when: - private_alb_enabled: true - type: boolean - - $template: ../../partials/templates/alb-certificate-input.yml - with: - additional_certificate_arns_description: Additional ACM certificate ARNs attached to the private ALB HTTPS listener for SNI. - additional_certificate_arns_input_id: private_alb_additional_certificate_arns - certificate_arns_description: ACM certificate ARNs resolved from the selected module. The first ARN is the default certificate. - certificate_arns_input_id: private_alb_certificate_arns - certificate_description: Primary ACM certificate module for private ALB HTTPS. - certificate_input_id: private_alb_certificate - show_when: - private_alb_enabled: true - private_alb_https_enabled: true - - collapsible: true - description: SSL policy for private ALB HTTPS. - id: private_alb_ssl_policy - label: SSL policy - placeholder: ELBSecurityPolicy-TLS13-1-2-2021-06 - show_when: - private_alb_enabled: true - private_alb_https_enabled: true - type: string - - add_button_label: Add CIDR block - collapsible: true - description: Allowed IPv4 CIDRs for private ALB access. Terraform defaults to RFC1918 private ranges. - id: private_alb_ingress_cidr_blocks - label: Ingress CIDRs - placeholder: 10.0.0.0/8 - show_when: - private_alb_enabled: true - type: string_array - - add_button_label: Add IPv6 CIDR block - collapsible: true - description: Allowed IPv6 CIDRs for private ALB access. Defaults to no IPv6 ingress. - id: private_alb_ingress_ipv6_cidr_blocks - label: Ingress IPv6 CIDRs - placeholder: fd00::/8 - show_when: - private_alb_enabled: true - type: string_array - - add_button_label: Add security group - collapsible: true - description: Security groups whose members can access the private ALB. Useful for sources without static CIDRs, such as CloudFront VPC origins. - id: private_alb_ingress_security_group_ids - label: Ingress security groups - placeholder: sg-... - show_when: - private_alb_enabled: true - type: string_array - - collapsible: true - description: Idle timeout for the private ALB. - id: private_alb_idle_timeout - label: Idle timeout (seconds) - max: 4000 - min: 1 - placeholder: "60" - show_when: - private_alb_enabled: true - type: number - - collapsible: true - default: false - description: Enable private ALB access logging. - id: private_alb_access_logs_enabled - label: Access logs - show_when: - private_alb_enabled: true - type: boolean - - collapsible: true - description: Existing S3 bucket ARN for private ALB access logs. - id: private_alb_access_logs_bucket_arn - label: Access logs bucket ARN - placeholder: arn:aws:s3:::my-bucket - show_when: - private_alb_enabled: true - private_alb_access_logs_enabled: true - type: string + - label: Private load balancers + default: [] + description: Each entry creates one internal Application Load Balancer. Services on this cluster choose a load balancer by name. + id: private_albs + item_label: Private load balancer + item_title: + template: "{name}" + item_inputs: + - description: Unique load balancer name. + id: name + label: Name + patterns: + - message: The name must be 1-32 characters, contain only letters, numbers, and hyphens, and start and end with a letter or number. + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,30}[a-zA-Z0-9])?$ + required: true + type: string + - default: false + description: Enable an HTTPS listener on this load balancer. + id: https_enabled + label: HTTPS + type: boolean + - $template: ../../partials/templates/alb-certificate-input.yml + with: + additional_certificate_arns_description: Additional ACM certificate ARNs attached to the HTTPS listener for SNI. + additional_certificate_arns_input_id: additional_certificate_arns + certificate_arns_description: ACM certificate ARNs resolved from the selected module. The first ARN is the default certificate. + certificate_arns_input_id: certificate_arns + certificate_description: Primary ACM certificate module for HTTPS. + certificate_input_id: certificate + show_when: + https_enabled: true + - collapsible: true + description: SSL policy for the HTTPS listener. + id: ssl_policy + label: SSL policy + placeholder: ELBSecurityPolicy-TLS13-1-2-2021-06 + show_when: + https_enabled: true + type: string + - add_button_label: Add CIDR block + collapsible: true + description: Allowed IPv4 CIDRs. Terraform defaults to RFC1918 private ranges. + id: ingress_cidr_blocks + label: Ingress CIDRs + placeholder: 10.0.0.0/8 + type: string_array + - add_button_label: Add IPv6 CIDR block + collapsible: true + description: Allowed IPv6 CIDRs. Defaults to no IPv6 ingress. + id: ingress_ipv6_cidr_blocks + label: Ingress IPv6 CIDRs + placeholder: fd00::/8 + type: string_array + - add_button_label: Add security group + collapsible: true + description: Security groups whose members can access this load balancer. Useful for sources without static CIDRs, such as CloudFront VPC origins. + id: ingress_security_group_ids + label: Ingress security groups + placeholder: sg-... + type: string_array + - collapsible: true + description: Idle timeout for this load balancer. + id: idle_timeout + label: Idle timeout (seconds) + max: 4000 + min: 1 + placeholder: "60" + type: number + - collapsible: true + default: false + description: Enable access logging for this load balancer. + id: access_logs_enabled + label: Access logs + type: boolean + - collapsible: true + description: Existing S3 bucket ARN for access logs. + id: access_logs_bucket_arn + label: Access logs bucket ARN + placeholder: arn:aws:s3:::my-bucket + show_when: + access_logs_enabled: true + type: string + type: object_array - description: Use Network Load Balancers for TCP/UDP workloads, static IP requirements, very high-throughput connections, or protocols that do not need HTTP routing. For normal web apps and HTTP APIs, prefer the Application Load Balancer sections above. id: section_nlb label: Network load balancers type: section - - default: false - description: Create an internet-facing Network Load Balancer. - id: public_nlb_enabled - label: Public NLB - type: boolean - - add_button_label: Add security group ID - collapsible: true - description: Security groups for the public NLB. - id: public_nlb_security_group_ids - label: Security groups - placeholder: sg-... - show_when: - public_nlb_enabled: true - type: string_array - - collapsible: true - default: false - description: Enable cross-zone load balancing for the public NLB. - id: public_nlb_cross_zone_load_balancing_enabled - label: Cross-zone load balancing - show_when: - public_nlb_enabled: true - type: boolean - - collapsible: true - default: false - description: Assign static Elastic IPs to the public NLB. - id: public_nlb_elastic_ips_enabled - label: Use elastic IPs - show_when: - public_nlb_enabled: true - type: boolean - - add_button_label: Add allocation ID - collapsible: true - description: Elastic IP allocation IDs for the public NLB, one per subnet. - id: public_nlb_elastic_ip_allocation_ids - label: Elastic IP allocation IDs - placeholder: eipalloc-... - show_when: - public_nlb_enabled: true - public_nlb_elastic_ips_enabled: true - type: string_array - - collapsible: true - default: false - description: Enable public NLB access logging. - id: public_nlb_access_logs_enabled - label: Access logs - show_when: - public_nlb_enabled: true - type: boolean - - collapsible: true - description: Existing S3 bucket ARN for public NLB access logs. - id: public_nlb_access_logs_bucket_arn - label: Access logs bucket ARN - placeholder: arn:aws:s3:::my-bucket - show_when: - public_nlb_enabled: true - public_nlb_access_logs_enabled: true - type: string - - default: false - description: Create an internal Network Load Balancer. - id: private_nlb_enabled - label: Private NLB - type: boolean - - add_button_label: Add security group ID - collapsible: true - description: Security groups for the private NLB. - id: private_nlb_security_group_ids - label: Security groups - placeholder: sg-... - show_when: - private_nlb_enabled: true - type: string_array - - collapsible: true - default: false - description: Enable cross-zone load balancing for the private NLB. - id: private_nlb_cross_zone_load_balancing_enabled - label: Cross-zone load balancing - show_when: - private_nlb_enabled: true - type: boolean - - collapsible: true - default: false - description: Assign static Elastic IPs to the private NLB. - id: private_nlb_elastic_ips_enabled - label: Use elastic IPs - show_when: - private_nlb_enabled: true - type: boolean - - add_button_label: Add allocation ID - collapsible: true - description: Elastic IP allocation IDs for the private NLB, one per subnet. - id: private_nlb_elastic_ip_allocation_ids - label: Elastic IP allocation IDs - placeholder: eipalloc-... - show_when: - private_nlb_enabled: true - private_nlb_elastic_ips_enabled: true - type: string_array - - collapsible: true - default: false - description: Enable private NLB access logging. - id: private_nlb_access_logs_enabled - label: Access logs - show_when: - private_nlb_enabled: true - type: boolean - - collapsible: true - description: Existing S3 bucket ARN for private NLB access logs. - id: private_nlb_access_logs_bucket_arn - label: Access logs bucket ARN - placeholder: arn:aws:s3:::my-bucket - show_when: - private_nlb_enabled: true - private_nlb_access_logs_enabled: true - type: string + - label: Public network load balancers + default: [] + description: Each entry creates one internet-facing Network Load Balancer. Services on this cluster choose a load balancer by name. + id: public_nlbs + item_label: Public NLB + item_title: + template: "{name}" + item_inputs: + - description: Unique load balancer name. + id: name + label: Name + patterns: + - message: The name must be 1-32 characters, contain only letters, numbers, and hyphens, and start and end with a letter or number. + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,30}[a-zA-Z0-9])?$ + required: true + type: string + - add_button_label: Add security group ID + collapsible: true + description: Security groups for this load balancer. + id: security_group_ids + label: Security groups + placeholder: sg-... + type: string_array + - collapsible: true + default: false + description: Enable cross-zone load balancing. + id: cross_zone_load_balancing_enabled + label: Cross-zone load balancing + type: boolean + - collapsible: true + default: false + description: Assign static Elastic IPs to this load balancer. + id: elastic_ips_enabled + label: Use elastic IPs + type: boolean + - add_button_label: Add allocation ID + collapsible: true + description: Elastic IP allocation IDs, one per subnet. + id: elastic_ip_allocation_ids + label: Elastic IP allocation IDs + placeholder: eipalloc-... + show_when: + elastic_ips_enabled: true + type: string_array + - collapsible: true + default: false + description: Enable access logging for this load balancer. + id: access_logs_enabled + label: Access logs + type: boolean + - collapsible: true + description: Existing S3 bucket ARN for access logs. + id: access_logs_bucket_arn + label: Access logs bucket ARN + placeholder: arn:aws:s3:::my-bucket + show_when: + access_logs_enabled: true + type: string + type: object_array + - label: Private network load balancers + default: [] + description: Each entry creates one internal Network Load Balancer. Services on this cluster choose a load balancer by name. + id: private_nlbs + item_label: Private NLB + item_title: + template: "{name}" + item_inputs: + - description: Unique load balancer name. + id: name + label: Name + patterns: + - message: The name must be 1-32 characters, contain only letters, numbers, and hyphens, and start and end with a letter or number. + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,30}[a-zA-Z0-9])?$ + required: true + type: string + - add_button_label: Add security group ID + collapsible: true + description: Security groups for this load balancer. + id: security_group_ids + label: Security groups + placeholder: sg-... + type: string_array + - collapsible: true + default: false + description: Enable cross-zone load balancing. + id: cross_zone_load_balancing_enabled + label: Cross-zone load balancing + type: boolean + - collapsible: true + default: false + description: Assign static Elastic IPs to this load balancer. + id: elastic_ips_enabled + label: Use elastic IPs + type: boolean + - add_button_label: Add allocation ID + collapsible: true + description: Elastic IP allocation IDs, one per subnet. + id: elastic_ip_allocation_ids + label: Elastic IP allocation IDs + placeholder: eipalloc-... + show_when: + elastic_ips_enabled: true + type: string_array + - collapsible: true + default: false + description: Enable access logging for this load balancer. + id: access_logs_enabled + label: Access logs + type: boolean + - collapsible: true + description: Existing S3 bucket ARN for access logs. + id: access_logs_bucket_arn + label: Access logs bucket ARN + placeholder: arn:aws:s3:::my-bucket + show_when: + access_logs_enabled: true + type: string + type: object_array - $include: ../../partials/inputs/misc-section.yml - $include: ../../partials/inputs/tags.yml - $include: ../../partials/inputs/terraform-settings.yml @@ -545,33 +575,33 @@ module: ## Load balancers - ### Public application load balancer + ### Public application load balancers - Creates an internet-facing Application Load Balancer for services that should receive public HTTP or HTTPS traffic, such as websites, public APIs, and webhook endpoints. + Creates internet-facing Application Load Balancers for services that should receive public HTTP or HTTPS traffic, such as websites, public APIs, and webhook endpoints. Add multiple entries when services need separate load balancers; each entry has its own name, HTTPS/certificate, timeout, WAF, and access log settings, and services select one by name. - Use the public ALB for: + Use a public ALB for: - Websites and browser apps - Public APIs - Webhooks - HTTP/HTTPS traffic with host or path routing - ### Private application load balancer + ### Private application load balancers - Creates an internal Application Load Balancer for services reachable only inside the VPC or private network, such as internal APIs, admin tools, and service-to-service traffic. + Creates internal Application Load Balancers for services reachable only inside the VPC or private network, such as internal APIs, admin tools, and service-to-service traffic. Add multiple entries when services need separate load balancers. - Use the private ALB for: + Use a private ALB for: - Internal APIs - Admin dashboards - Backend services - Private HTTP/HTTPS routing - For either ALB, the selected Certificate is the default HTTPS certificate. Use Additional certificate ARNs to attach more ACM certificates to the same listener through SNI. + For any ALB entry, the selected Certificate is the default HTTPS certificate. Use Additional certificate ARNs to attach more ACM certificates to the same listener through SNI. ### Network load balancers - Use Network Load Balancers for TCP/UDP workloads, static IP requirements, very high-throughput connections, or protocols that do not need HTTP routing. For normal web apps and HTTP APIs, prefer an Application Load Balancer. + Use Network Load Balancers for TCP/UDP workloads, static IP requirements, very high-throughput connections, or protocols that do not need HTTP routing. For normal web apps and HTTP APIs, prefer an Application Load Balancer. Public and private NLBs are also configured as arrays, so one cluster can own several NLBs and services select one by name. ## Observability @@ -596,10 +626,10 @@ module: | Fargate | No | `true` | Allow services to use Fargate capacity | | Fargate Spot | No | `true` | Allow services to use lower-cost interruptible Fargate Spot | | EC2 instance type | No | — | Enables EC2 capacity when selected | - | Public load balancer | No | `true` | Create an internet-facing ALB | - | Private load balancer | No | `false` | Create an internal ALB | - | Public NLB | No | `false` | Create an internet-facing Network Load Balancer | - | Private NLB | No | `false` | Create an internal Network Load Balancer | + | Public load balancers | No | one ALB | Array of internet-facing ALBs with per-entry settings | + | Private load balancers | No | `[]` | Array of internal ALBs with per-entry settings | + | Public NLBs | No | `[]` | Array of internet-facing Network Load Balancers | + | Private NLBs | No | `[]` | Array of internal Network Load Balancers | | Tags | No | Environment, Project | Custom tags applied to all resources | ## EC2 capacity settings @@ -623,7 +653,7 @@ module: - **Fargate-first defaults:** Fargate and Fargate Spot are enabled so services can start without managing instances - **EC2 opt-in:** EC2 capacity is created only when an instance type is selected - - **Shared load balancers:** Cluster-level ALBs and NLBs can be reused by services in the environment + - **Shared load balancers:** Cluster-level ALBs and NLBs can be reused by services in the environment, and each cluster can own several of each so services can select a specific one by name - **Private subnet placement:** ECS tasks and EC2 capacity use private subnets from the selected VPC - **Public ingress by default:** Public ALBs use Terraform's default internet ingress unless overridden in Terraform - **Private ingress by default:** Private ALBs use Terraform's default RFC1918 private CIDR ranges and no IPv6 ingress unless overridden @@ -666,44 +696,48 @@ module: container_insights: << module.input.container_insights >> fargate_enabled: << module.input.fargate_enabled >> fargate_spot_enabled: << module.input.fargate_spot_enabled >> - private_alb_enabled: << module.input.private_alb_enabled >> - private_nlb_enabled: << module.input.private_nlb_enabled >> - public_alb_enabled: << module.input.public_alb_enabled >> - public_nlb_enabled: << module.input.public_nlb_enabled >> name: << module.input.name >> - private_alb_access_logs_bucket_arn: << module.input.private_alb_access_logs_bucket_arn >> - private_alb_certificate_arns: >- - << (module.input.private_alb_certificate_arns != nil ? module.input.private_alb_certificate_arns : []) | - concat(module.input.private_alb_additional_certificate_arns != nil ? module.input.private_alb_additional_certificate_arns : []) >> - private_alb_access_logs_enabled: << module.input.private_alb_access_logs_enabled >> - private_alb_https_enabled: << module.input.private_alb_https_enabled >> - private_alb_idle_timeout: << module.input.private_alb_idle_timeout >> - private_alb_ingress_cidr_blocks: << module.input.private_alb_ingress_cidr_blocks >> - private_alb_ingress_ipv6_cidr_blocks: << module.input.private_alb_ingress_ipv6_cidr_blocks >> - private_alb_ingress_security_group_ids: << module.input.private_alb_ingress_security_group_ids >> - private_alb_ssl_policy: << module.input.private_alb_ssl_policy >> - private_nlb_access_logs_bucket_arn: << module.input.private_nlb_access_logs_bucket_arn >> - private_nlb_elastic_ip_allocation_ids: << module.input.private_nlb_elastic_ip_allocation_ids >> - private_nlb_access_logs_enabled: << module.input.private_nlb_access_logs_enabled >> - private_nlb_cross_zone_load_balancing_enabled: << module.input.private_nlb_cross_zone_load_balancing_enabled >> - private_nlb_elastic_ips_enabled: << module.input.private_nlb_elastic_ips_enabled >> - private_nlb_security_group_ids: << module.input.private_nlb_security_group_ids >> + public_albs: >- + << map(module.input.public_albs != nil ? module.input.public_albs : [], {"name": #.name, + "https_enabled": #.https_enabled, "certificate_arns": ((#.certificate_arns != nil ? + #.certificate_arns : []) | concat(#.additional_certificate_arns != nil ? + #.additional_certificate_arns : [])), "ssl_policy": #.ssl_policy, "idle_timeout": + #.idle_timeout, "ingress_cidr_blocks": (#.ingress_cidr_blocks != nil && + len(#.ingress_cidr_blocks) > 0 ? #.ingress_cidr_blocks : nil), "ingress_ipv6_cidr_blocks": + (#.ingress_ipv6_cidr_blocks != nil && len(#.ingress_ipv6_cidr_blocks) > 0 ? + #.ingress_ipv6_cidr_blocks : nil), "ingress_security_group_ids": + (#.ingress_security_group_ids != nil ? #.ingress_security_group_ids : []), + "web_acl_arn": #.web_acl_arn, "access_logs_enabled": #.access_logs_enabled, + "access_logs_bucket_arn": #.access_logs_bucket_arn}) >> + private_albs: >- + << map(module.input.private_albs != nil ? module.input.private_albs : [], {"name": #.name, + "https_enabled": #.https_enabled, "certificate_arns": ((#.certificate_arns != nil ? + #.certificate_arns : []) | concat(#.additional_certificate_arns != nil ? + #.additional_certificate_arns : [])), "ssl_policy": #.ssl_policy, "idle_timeout": + #.idle_timeout, "ingress_cidr_blocks": (#.ingress_cidr_blocks != nil && + len(#.ingress_cidr_blocks) > 0 ? #.ingress_cidr_blocks : nil), "ingress_ipv6_cidr_blocks": + (#.ingress_ipv6_cidr_blocks != nil && len(#.ingress_ipv6_cidr_blocks) > 0 ? + #.ingress_ipv6_cidr_blocks : nil), "ingress_security_group_ids": + (#.ingress_security_group_ids != nil ? #.ingress_security_group_ids : []), + "access_logs_enabled": #.access_logs_enabled, "access_logs_bucket_arn": + #.access_logs_bucket_arn}) >> + public_nlbs: >- + << map(module.input.public_nlbs != nil ? module.input.public_nlbs : [], {"name": #.name, + "cross_zone_load_balancing_enabled": #.cross_zone_load_balancing_enabled, + "security_group_ids": (#.security_group_ids != nil ? #.security_group_ids : []), + "elastic_ips_enabled": #.elastic_ips_enabled, "elastic_ip_allocation_ids": + (#.elastic_ip_allocation_ids != nil ? #.elastic_ip_allocation_ids : []), + "access_logs_enabled": #.access_logs_enabled, "access_logs_bucket_arn": + #.access_logs_bucket_arn}) >> + private_nlbs: >- + << map(module.input.private_nlbs != nil ? module.input.private_nlbs : [], {"name": #.name, + "cross_zone_load_balancing_enabled": #.cross_zone_load_balancing_enabled, + "security_group_ids": (#.security_group_ids != nil ? #.security_group_ids : []), + "elastic_ips_enabled": #.elastic_ips_enabled, "elastic_ip_allocation_ids": + (#.elastic_ip_allocation_ids != nil ? #.elastic_ip_allocation_ids : []), + "access_logs_enabled": #.access_logs_enabled, "access_logs_bucket_arn": + #.access_logs_bucket_arn}) >> private_subnet_ids: << module.input.private_subnet_ids >> - public_alb_access_logs_bucket_arn: << module.input.public_alb_access_logs_bucket_arn >> - public_alb_certificate_arns: >- - << (module.input.public_alb_certificate_arns != nil ? module.input.public_alb_certificate_arns : []) | - concat(module.input.public_alb_additional_certificate_arns != nil ? module.input.public_alb_additional_certificate_arns : []) >> - public_alb_access_logs_enabled: << module.input.public_alb_access_logs_enabled >> - public_alb_https_enabled: << module.input.public_alb_https_enabled >> - public_alb_idle_timeout: << module.input.public_alb_idle_timeout >> - public_alb_ssl_policy: << module.input.public_alb_ssl_policy >> - public_alb_web_acl_arn: << module.input.public_alb_web_acl_arn >> - public_nlb_access_logs_bucket_arn: << module.input.public_nlb_access_logs_bucket_arn >> - public_nlb_elastic_ip_allocation_ids: << module.input.public_nlb_elastic_ip_allocation_ids >> - public_nlb_access_logs_enabled: << module.input.public_nlb_access_logs_enabled >> - public_nlb_cross_zone_load_balancing_enabled: << module.input.public_nlb_cross_zone_load_balancing_enabled >> - public_nlb_elastic_ips_enabled: << module.input.public_nlb_elastic_ips_enabled >> - public_nlb_security_group_ids: << module.input.public_nlb_security_group_ids >> public_subnet_ids: << module.input.public_subnet_ids || [] >> region: << module.input.aws_region >> tags: @@ -712,10 +746,10 @@ module: ui: links: |- << - (module.input.public_alb_enabled && stack.output.public_alb_dns_name ? [{"name":"Public ALB","href":(module.input.public_alb_https_enabled ? "https://" : "http://") + stack.output.public_alb_dns_name}] : []) + - (module.input.private_alb_enabled && stack.output.private_alb_dns_name ? [{"name":"Private ALB","href":(module.input.private_alb_https_enabled ? "https://" : "http://") + stack.output.private_alb_dns_name}] : []) + - (module.input.public_nlb_enabled && stack.output.public_nlb_dns_name ? [{"name":"Public NLB","href":"http://" + stack.output.public_nlb_dns_name}] : []) + - (module.input.private_nlb_enabled && stack.output.private_nlb_dns_name ? [{"name":"Private NLB","href":"http://" + stack.output.private_nlb_dns_name}] : []) + map(stack.output.public_albs != nil ? stack.output.public_albs : [], {"name": "Public ALB " + #.name, "href": (#.https_enabled ? "https://" : "http://") + #.dns_name}) + + map(stack.output.private_albs != nil ? stack.output.private_albs : [], {"name": "Private ALB " + #.name, "href": (#.https_enabled ? "https://" : "http://") + #.dns_name}) + + map(stack.output.public_nlbs != nil ? stack.output.public_nlbs : [], {"name": "Public NLB " + #.name, "href": "http://" + #.dns_name}) + + map(stack.output.private_nlbs != nil ? stack.output.private_nlbs : [], {"name": "Private NLB " + #.name, "href": "http://" + #.dns_name}) >> metrics: - id: task_cpu_utilization diff --git a/compute/ecs_cluster/tests/basic.tftest.hcl b/compute/ecs_cluster/tests/basic.tftest.hcl index 5a660463..b38a65de 100644 --- a/compute/ecs_cluster/tests/basic.tftest.hcl +++ b/compute/ecs_cluster/tests/basic.tftest.hcl @@ -565,13 +565,8 @@ run "ec2_security_group_egress" { } assert { - condition = length(aws_vpc_security_group_egress_rule.ecs_instance_all) == 1 - error_message = "EC2 security group should have egress rule" - } - - assert { - condition = aws_vpc_security_group_egress_rule.ecs_instance_all[0].ip_protocol == "-1" - error_message = "EC2 security group should allow all outbound traffic" + condition = length(module.ecs_instance_security_group) == 1 + error_message = "EC2 instance security group should be created" } } @@ -622,196 +617,20 @@ run "ec2_spot_enabled" { } } -################################################################################ -# Public ALB Tests -################################################################################ -# Test 21: Public ALB disabled by default -run "public_alb_disabled_by_default" { - command = plan - assert { - condition = length(module.public_alb) == 0 - error_message = "Public ALB should not be created by default" - } -} -# Test 22: Public ALB enabled -run "public_alb_enabled" { - command = plan - variables { - public_alb_enabled = true - public_subnet_ids = ["subnet-public1", "subnet-public2"] - } - assert { - condition = length(module.public_alb) == 1 - error_message = "Public ALB should be created when enabled" - } -} -# Test 23: Public ALB with HTTPS -run "public_alb_with_https" { - command = plan - variables { - public_alb_enabled = true - public_subnet_ids = ["subnet-public1", "subnet-public2"] - public_alb_https_enabled = true - public_alb_certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"] - } - assert { - condition = length(module.public_alb) == 1 - error_message = "Public ALB should be created" - } -} -# Test 24: Public ALB custom settings -run "public_alb_custom_settings" { - command = plan - - variables { - public_alb_enabled = true - public_subnet_ids = ["subnet-public1", "subnet-public2"] - public_alb_idle_timeout = 120 - load_balancer_deletion_protection_enabled = false - public_alb_ingress_cidr_blocks = ["10.0.0.0/8"] - } - - assert { - condition = length(module.public_alb) == 1 - error_message = "Public ALB should be created with custom settings" - } -} - -################################################################################ -# Private ALB Tests -################################################################################ - -# Test 25: Private ALB disabled by default -run "private_alb_disabled_by_default" { - command = plan - - assert { - condition = length(module.private_alb) == 0 - error_message = "Private ALB should not be created by default" - } -} - -# Test 26: Private ALB enabled -run "private_alb_enabled" { - command = plan - - variables { - private_alb_enabled = true - } - - assert { - condition = length(module.private_alb) == 1 - error_message = "Private ALB should be created when enabled" - } -} - -# Test 27: Private ALB with HTTPS -run "private_alb_with_https" { - command = plan - - variables { - private_alb_enabled = true - private_alb_https_enabled = true - private_alb_certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"] - } - - assert { - condition = length(module.private_alb) == 1 - error_message = "Private ALB should be created with HTTPS" - } -} - -# Test 28: Private ALB custom settings -run "private_alb_custom_settings" { - command = plan - - variables { - private_alb_enabled = true - private_alb_idle_timeout = 90 - load_balancer_deletion_protection_enabled = true - private_alb_ingress_cidr_blocks = ["192.168.0.0/16"] - } - - assert { - condition = length(module.private_alb) == 1 - error_message = "Private ALB should be created with custom settings" - } -} - -# Test 28b: Private ALB ingress rules referencing source security groups -run "private_alb_ingress_security_groups" { - command = plan - - variables { - private_alb_enabled = true - private_alb_https_enabled = true - private_alb_certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"] - private_alb_ingress_security_group_ids = ["sg-0123456789abcdef0"] - } - - assert { - condition = length([ - for rule in module.private_alb[0].module.security_group.aws_vpc_security_group_ingress_rule.this : rule - if rule.referenced_security_group_id == "sg-0123456789abcdef0" - ]) == 2 - error_message = "Private ALB should have HTTP and HTTPS ingress rules referencing the source security group" - } -} - -# Test 28c: Public ALB ingress rules referencing source security groups -run "public_alb_ingress_security_groups" { - command = plan - - variables { - public_alb_enabled = true - public_alb_https_enabled = true - public_alb_certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"] - public_subnet_ids = ["subnet-public1", "subnet-public2"] - public_alb_ingress_security_group_ids = ["sg-0123456789abcdef0"] - } - - assert { - condition = length([ - for rule in module.public_alb[0].module.security_group.aws_vpc_security_group_ingress_rule.this : rule - if rule.referenced_security_group_id == "sg-0123456789abcdef0" - ]) == 2 - error_message = "Public ALB should have HTTP and HTTPS ingress rules referencing the source security group" - } -} ################################################################################ # Combined Configuration Tests ################################################################################ -# Test 29: Both public and private ALBs -run "both_albs_enabled" { - command = plan - - variables { - public_alb_enabled = true - private_alb_enabled = true - public_subnet_ids = ["subnet-public1", "subnet-public2"] - } - - assert { - condition = length(module.public_alb) == 1 - error_message = "Public ALB should be created" - } - - assert { - condition = length(module.private_alb) == 1 - error_message = "Private ALB should be created" - } -} # Test 30: Full configuration with EC2 and Fargate run "full_configuration" { @@ -843,57 +662,8 @@ run "full_configuration" { } } -# Test 31: EC2 with public ALB - security group ingress -run "ec2_with_public_alb_ingress" { - command = plan - - variables { - ec2_instance_type = "t3.medium" - public_alb_enabled = true - public_subnet_ids = ["subnet-public1", "subnet-public2"] - } - - assert { - condition = length(aws_vpc_security_group_ingress_rule.ecs_instance_from_public_alb) == 1 - error_message = "EC2 security group should have ingress rule from public ALB" - } -} - -# Test 32: EC2 with private ALB - security group ingress -run "ec2_with_private_alb_ingress" { - command = plan - - variables { - ec2_instance_type = "t3.medium" - private_alb_enabled = true - } - - assert { - condition = length(aws_vpc_security_group_ingress_rule.ecs_instance_from_private_alb) == 1 - error_message = "EC2 security group should have ingress rule from private ALB" - } -} - -# Test 33: EC2 without ALBs - no ALB ingress rules -run "ec2_without_albs_no_ingress" { - command = plan - variables { - ec2_instance_type = "t3.medium" - public_alb_enabled = false - private_alb_enabled = false - } - - assert { - condition = length(aws_vpc_security_group_ingress_rule.ecs_instance_from_public_alb) == 0 - error_message = "EC2 security group should not have public ALB ingress rule when ALB disabled" - } - assert { - condition = length(aws_vpc_security_group_ingress_rule.ecs_instance_from_private_alb) == 0 - error_message = "EC2 security group should not have private ALB ingress rule when ALB disabled" - } -} # Test 34: No capacity providers (validation - at least one should be enabled) run "no_fargate_providers" { diff --git a/compute/ecs_cluster/tests/load_balancers.tftest.hcl b/compute/ecs_cluster/tests/load_balancers.tftest.hcl new file mode 100644 index 00000000..d071e266 --- /dev/null +++ b/compute/ecs_cluster/tests/load_balancers.tftest.hcl @@ -0,0 +1,530 @@ +# ECS Cluster Load Balancer Tests +# Run with: tofu test + +mock_provider "aws" { + override_data { + target = data.aws_caller_identity.current + values = { + account_id = "123456789012" + } + } + + override_data { + target = data.aws_region.current + values = { + id = "us-east-1" + name = "us-east-1" + } + } + + override_data { + target = data.aws_elb_service_account.current + values = { + arn = "arn:aws:iam::127311923021:root" + } + } + + # Override ALB/NLB resources so mock ARNs are valid + override_resource { + target = module.public_alb.aws_lb.this + values = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-public-alb/1234567890123456" + arn_suffix = "app/test-public-alb/1234567890123456" + dns_name = "test-public-alb-123456789.us-east-1.elb.amazonaws.com" + zone_id = "Z35SXDOTRQ7X7K" + } + } + + override_resource { + target = module.public_alb.aws_lb_listener.http + values = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-public-alb/1234567890123456/1234567890123456" + } + } + + override_resource { + target = module.public_alb.aws_lb_listener.https + values = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-public-alb/1234567890123456/6543210987654321" + } + } + + override_resource { + target = aws_launch_template.ecs + values = { + arn = "arn:aws:ec2:us-east-1:123456789012:launch-template/lt-0123456789abcdef" + id = "lt-0123456789abcdef" + } + } + + override_resource { + target = module.ecs_instance_security_group.aws_security_group.this + values = { + arn = "arn:aws:ec2:us-east-1:123456789012:security-group/sg-ecs123456789" + id = "sg-ecs123456789" + } + } + + override_data { + target = data.aws_ssm_parameter.ecs_optimized_ami + values = { + value = "ami-0123456789abcdef0" + } + } + + override_resource { + target = module.ecs_autoscaling.aws_autoscaling_group.this + values = { + arn = "arn:aws:autoscaling:us-east-1:123456789012:autoScalingGroup:12345678-1234-1234-1234-123456789012:autoScalingGroupName/test-cluster-ecs" + } + } + + override_resource { + target = aws_iam_instance_profile.ecs_instance + values = { + arn = "arn:aws:iam::123456789012:instance-profile/test-cluster-ecs-instance" + } + } + + override_resource { + target = module.public_alb.module.security_group.aws_security_group.this + values = { + arn = "arn:aws:ec2:us-east-1:123456789012:security-group/sg-publicalb123456" + id = "sg-publicalb123456" + } + } + + override_resource { + target = module.private_alb.module.security_group.aws_security_group.this + values = { + arn = "arn:aws:ec2:us-east-1:123456789012:security-group/sg-privatealb123456" + id = "sg-privatealb123456" + } + } + + override_resource { + target = module.public_alb.aws_security_group.this + values = { + arn = "arn:aws:ec2:us-east-1:123456789012:security-group/sg-publicalb123456" + id = "sg-publicalb123456" + } + } + + override_resource { + target = module.public_alb.aws_s3_bucket.access_logs + values = { + arn = "arn:aws:s3:::test-public-alb-access-logs-123456789012-us-east-1" + id = "test-public-alb-access-logs-123456789012-us-east-1" + } + } + + override_resource { + target = module.private_alb.aws_lb.this + values = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/test-private-alb/1234567890123457" + arn_suffix = "app/test-private-alb/1234567890123457" + dns_name = "test-private-alb-123456789.us-east-1.elb.amazonaws.com" + zone_id = "Z35SXDOTRQ7X7K" + } + } + + override_resource { + target = module.private_alb.aws_lb_listener.http + values = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-private-alb/1234567890123457/1234567890123457" + } + } + + override_resource { + target = module.private_alb.aws_lb_listener.https + values = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-private-alb/1234567890123457/6543210987654322" + } + } + + override_resource { + target = module.private_alb.aws_security_group.this + values = { + arn = "arn:aws:ec2:us-east-1:123456789012:security-group/sg-privatealb123456" + id = "sg-privatealb123456" + } + } + + override_resource { + target = module.private_alb.aws_s3_bucket.access_logs + values = { + arn = "arn:aws:s3:::test-private-alb-access-logs-123456789012-us-east-1" + id = "test-private-alb-access-logs-123456789012-us-east-1" + } + } + + override_resource { + target = module.public_nlb.aws_lb.this + values = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/test-public-nlb/1234567890123458" + arn_suffix = "net/test-public-nlb/1234567890123458" + dns_name = "test-public-nlb-123456789.us-east-1.elb.amazonaws.com" + zone_id = "Z26RNL4JYFTOTI" + } + } + + override_resource { + target = module.private_nlb.aws_lb.this + values = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/net/test-private-nlb/1234567890123459" + arn_suffix = "net/test-private-nlb/1234567890123459" + dns_name = "test-private-nlb-123456789.us-east-1.elb.amazonaws.com" + zone_id = "Z26RNL4JYFTOTI" + } + } +} + +variables { + name = "test-cluster" + vpc_id = "vpc-12345678" + private_subnet_ids = ["subnet-private1", "subnet-private2"] +} + +################################################################################ +# ALB Tests +################################################################################ + +# Test 21: Public ALB disabled by default +run "public_alb_disabled_by_default" { + command = plan + + assert { + condition = length(module.public_alb) == 0 + error_message = "Public ALB should not be created by default" + } +} + +# Test 22: Public ALB enabled +run "public_alb_enabled" { + command = plan + + variables { + public_albs = [{}] + public_subnet_ids = ["subnet-public1", "subnet-public2"] + } + + assert { + condition = length(module.public_alb) == 1 + error_message = "Public ALB should be created when enabled" + } +} + +# Test 23: Public ALB with HTTPS +run "public_alb_with_https" { + command = plan + + variables { + public_subnet_ids = ["subnet-public1", "subnet-public2"] + public_albs = [{ + https_enabled = true + certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"] + }] + } + + assert { + condition = length(module.public_alb) == 1 + error_message = "Public ALB should be created" + } +} + +# Test 24: Public ALB custom settings +run "public_alb_custom_settings" { + command = plan + + variables { + public_subnet_ids = ["subnet-public1", "subnet-public2"] + load_balancer_deletion_protection_enabled = false + public_albs = [{ + idle_timeout = 120 + ingress_cidr_blocks = ["10.0.0.0/8"] + }] + } + + assert { + condition = length(module.public_alb) == 1 + error_message = "Public ALB should be created with custom settings" + } +} + +# Test 25: Private ALB disabled by default +run "private_alb_disabled_by_default" { + command = plan + + assert { + condition = length(module.private_alb) == 0 + error_message = "Private ALB should not be created by default" + } +} + +# Test 26: Private ALB enabled +run "private_alb_enabled" { + command = plan + + variables { + private_albs = [{}] + } + + assert { + condition = length(module.private_alb) == 1 + error_message = "Private ALB should be created when enabled" + } +} + +# Test 27: Private ALB with HTTPS +run "private_alb_with_https" { + command = plan + + variables { + private_albs = [{ + https_enabled = true + certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"] + }] + } + + assert { + condition = length(module.private_alb) == 1 + error_message = "Private ALB should be created with HTTPS" + } +} + +# Test 28: Private ALB custom settings +run "private_alb_custom_settings" { + command = plan + + variables { + load_balancer_deletion_protection_enabled = true + private_albs = [{ + idle_timeout = 90 + ingress_cidr_blocks = ["192.168.0.0/16"] + }] + } + + assert { + condition = length(module.private_alb) == 1 + error_message = "Private ALB should be created with custom settings" + } +} + +# Test 28b: Private ALB ingress rules referencing source security groups +run "private_alb_ingress_security_groups" { + command = plan + + variables { + private_albs = [{ + https_enabled = true + certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"] + ingress_security_group_ids = ["sg-0123456789abcdef0"] + }] + } + + assert { + condition = length(module.private_alb) == 1 + error_message = "Private ALB should be created with ingress security groups" + } +} + +# Test 28c: Public ALB ingress rules referencing source security groups +run "public_alb_ingress_security_groups" { + command = plan + + variables { + public_subnet_ids = ["subnet-public1", "subnet-public2"] + public_albs = [{ + https_enabled = true + certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"] + ingress_security_group_ids = ["sg-0123456789abcdef0"] + }] + } + + assert { + condition = length(module.public_alb) == 1 + error_message = "Public ALB should be created with ingress security groups" + } +} + +# Test 29: Both public and private ALBs +run "both_albs_enabled" { + command = plan + + variables { + public_albs = [{}] + private_albs = [{}] + public_subnet_ids = ["subnet-public1", "subnet-public2"] + } + + assert { + condition = length(module.public_alb) == 1 + error_message = "Public ALB should be created" + } + + assert { + condition = length(module.private_alb) == 1 + error_message = "Private ALB should be created" + } +} + +# Test 31: EC2 with public ALB - security group ingress +run "ec2_with_public_alb_ingress" { + command = plan + + variables { + ec2_instance_type = "t3.medium" + public_albs = [{}] + public_subnet_ids = ["subnet-public1", "subnet-public2"] + } + + assert { + condition = length(module.ecs_instance_security_group[0].ingress_rule_ids) == 1 + error_message = "EC2 security group should have ingress rule from public ALB" + } +} + +# Test 32: EC2 with private ALB - security group ingress +run "ec2_with_private_alb_ingress" { + command = plan + + variables { + ec2_instance_type = "t3.medium" + private_albs = [{}] + } + + assert { + condition = length(module.ecs_instance_security_group[0].ingress_rule_ids) == 1 + error_message = "EC2 security group should have ingress rule from private ALB" + } +} + +# Test 33: EC2 without ALBs - no ALB ingress rules +run "ec2_without_albs_no_ingress" { + command = plan + + variables { + ec2_instance_type = "t3.medium" + public_albs = [] + private_albs = [] + } + + assert { + condition = length(module.ecs_instance_security_group[0].ingress_rule_ids) == 0 + error_message = "EC2 security group should not have ALB ingress rules when no ALBs exist" + } +} + +################################################################################ +# Multiple Load Balancer Tests +################################################################################ + +run "multiple_public_albs" { + command = plan + + variables { + public_subnet_ids = ["subnet-public1", "subnet-public2"] + public_albs = [ + {}, + { name = "custom-alb-name", https_enabled = true, certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012"] }, + ] + } + + assert { + condition = length(module.public_alb) == 2 + error_message = "Two public ALBs should be created" + } + + assert { + condition = local.public_alb_names[0] == "test-cluster-pub" && local.public_alb_names[1] == "custom-alb-name" + error_message = "Public ALB names should use the default for the first entry and the explicit name for the second" + } +} + +run "public_alb_addresses_keyed_by_name" { + command = plan + + variables { + public_subnet_ids = ["subnet-public1", "subnet-public2"] + public_albs = [ + { name = "alb-b" }, + { name = "alb-a" }, + ] + } + + assert { + condition = contains(keys(module.public_alb), "alb-a") && contains(keys(module.public_alb), "alb-b") + error_message = "Public ALB module instances should be keyed by resolved name so addresses stay stable when entries are reordered" + } + + assert { + condition = output.public_albs[0].name == "alb-b" && output.public_albs[1].name == "alb-a" + error_message = "Public ALB outputs should preserve the input order" + } +} + +run "multiple_private_albs_default_names" { + command = plan + + variables { + private_albs = [{}, {}] + } + + assert { + condition = length(module.private_alb) == 2 + error_message = "Two private ALBs should be created" + } + + assert { + condition = local.private_alb_names[0] == "test-cluster-priv" && local.private_alb_names[1] == "test-cluster-priv-2" + error_message = "Private ALB names should be deterministic" + } +} + +################################################################################ +# NLB Tests +################################################################################ + +run "nlbs_disabled_by_default" { + command = plan + + assert { + condition = length(module.public_nlb) == 0 && length(module.private_nlb) == 0 + error_message = "No NLBs should be created by default" + } +} + +run "multiple_public_nlbs" { + command = plan + + variables { + public_subnet_ids = ["subnet-public1", "subnet-public2"] + public_nlbs = [ + { cross_zone_load_balancing_enabled = true }, + { name = "custom-nlb" }, + ] + } + + assert { + condition = length(module.public_nlb) == 2 + error_message = "Two public NLBs should be created" + } + + assert { + condition = local.public_nlb_names[0] == "test-cluster-pub-nlb" && local.public_nlb_names[1] == "custom-nlb" + error_message = "Public NLB names should use the default for the first entry and the explicit name for the second" + } +} + +run "private_nlb_created" { + command = plan + + variables { + private_nlbs = [{}] + } + + assert { + condition = length(module.private_nlb) == 1 + error_message = "Private NLB should be created" + } +} diff --git a/compute/ecs_cluster/variables.tf b/compute/ecs_cluster/variables.tf index fff9d656..13b9548c 100644 --- a/compute/ecs_cluster/variables.tf +++ b/compute/ecs_cluster/variables.tf @@ -11,25 +11,6 @@ variable "name" { error_message = "The name must not be empty." } - validation { - condition = !var.public_alb_enabled || length(var.name) <= 28 - error_message = "The name must be 28 characters or less when public_alb_enabled is true so the public ALB name does not exceed the 32 character AWS limit." - } - - validation { - condition = !var.private_alb_enabled || length(var.name) <= 27 - error_message = "The name must be 27 characters or less when private_alb_enabled is true so the private ALB name does not exceed the 32 character AWS limit." - } - - validation { - condition = !var.public_nlb_enabled || length(var.name) <= 24 - error_message = "The name must be 24 characters or less when public_nlb_enabled is true so the public NLB name does not exceed the 32 character AWS limit." - } - - validation { - condition = !var.private_nlb_enabled || length(var.name) <= 23 - error_message = "The name must be 23 characters or less when private_nlb_enabled is true so the private NLB name does not exceed the 32 character AWS limit." - } } variable "tags" { @@ -73,14 +54,14 @@ variable "private_subnet_ids" { } validation { - condition = !var.private_alb_enabled || length(var.private_subnet_ids) >= 2 - error_message = "At least 2 private_subnet_ids are required when private_alb_enabled is true. ALBs require subnets in at least 2 availability zones for high availability." + condition = length(var.private_albs) == 0 || length(var.private_subnet_ids) >= 2 + error_message = "At least 2 private_subnet_ids are required when private_albs is non-empty. ALBs require subnets in at least 2 availability zones for high availability." } } variable "public_subnet_ids" { type = list(string) - description = "A list of public subnet IDs for the public ALB/NLB. Required if public_alb_enabled or public_nlb_enabled is true." + description = "A list of public subnet IDs for the public ALBs/NLBs. Required if public_albs or public_nlbs is non-empty." default = [] validation { @@ -89,13 +70,13 @@ variable "public_subnet_ids" { } validation { - condition = !var.public_alb_enabled || length(var.public_subnet_ids) >= 2 - error_message = "At least 2 public_subnet_ids are required when public_alb_enabled is true. ALBs require subnets in at least 2 availability zones for high availability." + condition = length(var.public_albs) == 0 || length(var.public_subnet_ids) >= 2 + error_message = "At least 2 public_subnet_ids are required when public_albs is non-empty. ALBs require subnets in at least 2 availability zones for high availability." } validation { - condition = !var.public_nlb_enabled || length(var.public_subnet_ids) >= 1 - error_message = "At least 1 public_subnet_id is required when public_nlb_enabled is true." + condition = length(var.public_nlbs) == 0 || length(var.public_subnet_ids) >= 1 + error_message = "At least 1 public_subnet_id is required when public_nlbs is non-empty." } } @@ -393,313 +374,218 @@ variable "ec2_security_group_ids" { } ################################################################################ -# Public ALB +# Load Balancers ################################################################################ -variable "public_alb_enabled" { - type = bool - description = "Enable a public (internet-facing) Application Load Balancer." - default = false -} - -variable "public_alb_https_enabled" { - type = bool - description = "Enable HTTPS listener on the public ALB." - default = false -} - -variable "public_alb_certificate_arns" { - type = list(string) - description = "ACM certificate ARNs for the public ALB HTTPS listener. The first ARN is used as the default certificate; the rest are attached for SNI." +variable "public_albs" { + type = list(object({ + name = optional(string) + https_enabled = optional(bool, false) + certificate_arns = optional(list(string), []) + ssl_policy = optional(string, "ELBSecurityPolicy-TLS13-1-2-2021-06") + idle_timeout = optional(number, 60) + ingress_cidr_blocks = optional(list(string), ["0.0.0.0/0"]) + ingress_ipv6_cidr_blocks = optional(list(string), ["::/0"]) + ingress_security_group_ids = optional(list(string), []) + access_logs_enabled = optional(bool, false) + access_logs_bucket_arn = optional(string) + web_acl_arn = optional(string) + })) + description = "Public (internet-facing) Application Load Balancers. Each entry creates one ALB with its own configuration. When name is null, the first entry is named '-pub' and later entries '-pub-'." default = [] validation { - condition = alltrue([for arn in var.public_alb_certificate_arns : can(regex("^arn:aws:acm:", arn))]) - error_message = "All public_alb_certificate_arns must be valid ACM certificate ARNs." + condition = alltrue([for lb in var.public_albs : alltrue([for arn in lb.certificate_arns : can(regex("^arn:aws:acm:", arn))])]) + error_message = "All public_albs certificate_arns must be valid ACM certificate ARNs." } -} - -variable "public_alb_ssl_policy" { - type = string - description = "The SSL policy for the public ALB HTTPS listener." - default = "ELBSecurityPolicy-TLS13-1-2-2021-06" -} - -variable "public_alb_idle_timeout" { - type = number - description = "The idle timeout for the public ALB in seconds." - default = 60 validation { - condition = var.public_alb_idle_timeout >= 1 && var.public_alb_idle_timeout <= 4000 - error_message = "The public_alb_idle_timeout must be between 1 and 4000 seconds." + condition = alltrue([for lb in var.public_albs : lb.idle_timeout >= 1 && lb.idle_timeout <= 4000]) + error_message = "Each public_albs idle_timeout must be between 1 and 4000 seconds." } -} - -variable "public_alb_ingress_cidr_blocks" { - type = list(string) - description = "IPv4 CIDR blocks allowed to access the public ALB." - default = ["0.0.0.0/0"] validation { - condition = alltrue([for cidr in var.public_alb_ingress_cidr_blocks : can(cidrhost(cidr, 0))]) - error_message = "All public_alb_ingress_cidr_blocks must be valid IPv4 CIDR blocks." + condition = alltrue([for lb in var.public_albs : alltrue([for cidr in lb.ingress_cidr_blocks : can(cidrhost(cidr, 0))])]) + error_message = "All public_albs ingress_cidr_blocks must be valid IPv4 CIDR blocks." } -} - -variable "public_alb_ingress_ipv6_cidr_blocks" { - type = list(string) - description = "IPv6 CIDR blocks allowed to access the public ALB." - default = ["::/0"] -} - -variable "public_alb_ingress_security_group_ids" { - type = list(string) - description = "Security group IDs whose members are allowed to access the public ALB." - default = [] validation { - condition = alltrue([for sg in var.public_alb_ingress_security_group_ids : can(regex("^sg-", sg))]) - error_message = "All public_alb_ingress_security_group_ids must be valid security group IDs starting with 'sg-'." + condition = alltrue([for lb in var.public_albs : alltrue([for sg in lb.ingress_security_group_ids : can(regex("^sg-", sg))])]) + error_message = "All public_albs ingress_security_group_ids must be valid security group IDs starting with 'sg-'." } -} - -variable "public_alb_access_logs_enabled" { - type = bool - description = "Enable access logging for the public ALB." - default = false -} - -variable "public_alb_access_logs_bucket_arn" { - type = string - description = "The ARN of an existing S3 bucket for public ALB access logs." - default = null validation { - condition = var.public_alb_access_logs_bucket_arn == null || can(regex("^arn:aws:s3:::", var.public_alb_access_logs_bucket_arn)) - error_message = "The public_alb_access_logs_bucket_arn must be a valid S3 bucket ARN." + condition = alltrue([for lb in var.public_albs : lb.access_logs_bucket_arn == null || can(regex("^arn:aws:s3:::", coalesce(lb.access_logs_bucket_arn, "invalid")))]) + error_message = "Each public_albs access_logs_bucket_arn must be a valid S3 bucket ARN." } -} - -variable "public_alb_web_acl_arn" { - type = string - description = "The ARN of a WAFv2 Web ACL to associate with the public ALB." - default = null validation { - condition = var.public_alb_web_acl_arn == null || can(regex("^arn:aws:wafv2:", var.public_alb_web_acl_arn)) - error_message = "The public_alb_web_acl_arn must be a valid WAFv2 Web ACL ARN." + condition = alltrue([for lb in var.public_albs : lb.web_acl_arn == null || can(regex("^arn:aws:wafv2:", coalesce(lb.web_acl_arn, "invalid")))]) + error_message = "Each public_albs web_acl_arn must be a valid WAFv2 Web ACL ARN." } -} -################################################################################ -# Private ALB -################################################################################ - -variable "private_alb_enabled" { - type = bool - description = "Enable a private (internal) Application Load Balancer." - default = false -} + validation { + condition = alltrue([ + for idx, lb in var.public_albs : + length(coalesce(lb.name, idx == 0 ? "${var.name}-pub" : "${var.name}-pub-${idx + 1}")) <= 32 + ]) + error_message = "Each public ALB name (explicit or derived from the module name) must be 32 characters or less." + } -variable "private_alb_https_enabled" { - type = bool - description = "Enable HTTPS listener on the private ALB." - default = false + validation { + condition = length(distinct([ + for idx, lb in var.public_albs : + coalesce(lb.name, idx == 0 ? "${var.name}-pub" : "${var.name}-pub-${idx + 1}") + ])) == length(var.public_albs) + error_message = "Each public ALB must have a unique name." + } } -variable "private_alb_certificate_arns" { - type = list(string) - description = "ACM certificate ARNs for the private ALB HTTPS listener. The first ARN is used as the default certificate; the rest are attached for SNI." +variable "private_albs" { + type = list(object({ + name = optional(string) + https_enabled = optional(bool, false) + certificate_arns = optional(list(string), []) + ssl_policy = optional(string, "ELBSecurityPolicy-TLS13-1-2-2021-06") + idle_timeout = optional(number, 60) + ingress_cidr_blocks = optional(list(string), ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]) + ingress_ipv6_cidr_blocks = optional(list(string), []) + ingress_security_group_ids = optional(list(string), []) + access_logs_enabled = optional(bool, false) + access_logs_bucket_arn = optional(string) + })) + description = "Private (internal) Application Load Balancers. Each entry creates one ALB with its own configuration. When name is null, the first entry is named '-priv' and later entries '-priv-'." default = [] validation { - condition = alltrue([for arn in var.private_alb_certificate_arns : can(regex("^arn:aws:acm:", arn))]) - error_message = "All private_alb_certificate_arns must be valid ACM certificate ARNs." + condition = alltrue([for lb in var.private_albs : alltrue([for arn in lb.certificate_arns : can(regex("^arn:aws:acm:", arn))])]) + error_message = "All private_albs certificate_arns must be valid ACM certificate ARNs." } -} - -variable "private_alb_ssl_policy" { - type = string - description = "The SSL policy for the private ALB HTTPS listener." - default = "ELBSecurityPolicy-TLS13-1-2-2021-06" -} - -variable "private_alb_idle_timeout" { - type = number - description = "The idle timeout for the private ALB in seconds." - default = 60 validation { - condition = var.private_alb_idle_timeout >= 1 && var.private_alb_idle_timeout <= 4000 - error_message = "The private_alb_idle_timeout must be between 1 and 4000 seconds." + condition = alltrue([for lb in var.private_albs : lb.idle_timeout >= 1 && lb.idle_timeout <= 4000]) + error_message = "Each private_albs idle_timeout must be between 1 and 4000 seconds." } -} - -variable "private_alb_ingress_cidr_blocks" { - type = list(string) - description = "IPv4 CIDR blocks allowed to access the private ALB." - default = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] validation { - condition = alltrue([for cidr in var.private_alb_ingress_cidr_blocks : can(cidrhost(cidr, 0))]) - error_message = "All private_alb_ingress_cidr_blocks must be valid IPv4 CIDR blocks." + condition = alltrue([for lb in var.private_albs : alltrue([for cidr in lb.ingress_cidr_blocks : can(cidrhost(cidr, 0))])]) + error_message = "All private_albs ingress_cidr_blocks must be valid IPv4 CIDR blocks." } -} - -variable "private_alb_ingress_ipv6_cidr_blocks" { - type = list(string) - description = "IPv6 CIDR blocks allowed to access the private ALB. Defaults to no IPv6 ingress; RFC1918 has no IPv6 equivalent." - default = [] -} - -variable "private_alb_ingress_security_group_ids" { - type = list(string) - description = "Security group IDs whose members are allowed to access the private ALB. Useful for sources without static CIDRs, such as CloudFront VPC origins." - default = [] validation { - condition = alltrue([for sg in var.private_alb_ingress_security_group_ids : can(regex("^sg-", sg))]) - error_message = "All private_alb_ingress_security_group_ids must be valid security group IDs starting with 'sg-'." + condition = alltrue([for lb in var.private_albs : alltrue([for sg in lb.ingress_security_group_ids : can(regex("^sg-", sg))])]) + error_message = "All private_albs ingress_security_group_ids must be valid security group IDs starting with 'sg-'." } -} - -variable "private_alb_access_logs_enabled" { - type = bool - description = "Enable access logging for the private ALB." - default = false -} - -variable "private_alb_access_logs_bucket_arn" { - type = string - description = "The ARN of an existing S3 bucket for private ALB access logs." - default = null validation { - condition = var.private_alb_access_logs_bucket_arn == null || can(regex("^arn:aws:s3:::", var.private_alb_access_logs_bucket_arn)) - error_message = "The private_alb_access_logs_bucket_arn must be a valid S3 bucket ARN." + condition = alltrue([for lb in var.private_albs : lb.access_logs_bucket_arn == null || can(regex("^arn:aws:s3:::", coalesce(lb.access_logs_bucket_arn, "invalid")))]) + error_message = "Each private_albs access_logs_bucket_arn must be a valid S3 bucket ARN." } -} -################################################################################ -# Public NLB -################################################################################ - -variable "public_nlb_enabled" { - type = bool - description = "Enable a public (internet-facing) Network Load Balancer." - default = false -} + validation { + condition = alltrue([ + for idx, lb in var.private_albs : + length(coalesce(lb.name, idx == 0 ? "${var.name}-priv" : "${var.name}-priv-${idx + 1}")) <= 32 + ]) + error_message = "Each private ALB name (explicit or derived from the module name) must be 32 characters or less." + } -variable "public_nlb_cross_zone_load_balancing_enabled" { - type = bool - description = "Enable cross-zone load balancing for the public NLB." - default = false + validation { + condition = length(distinct([ + for idx, lb in var.private_albs : + coalesce(lb.name, idx == 0 ? "${var.name}-priv" : "${var.name}-priv-${idx + 1}") + ])) == length(var.private_albs) + error_message = "Each private ALB must have a unique name." + } } -variable "public_nlb_security_group_ids" { - type = list(string) - description = "A list of security group IDs to attach to the public NLB." +variable "public_nlbs" { + type = list(object({ + name = optional(string) + cross_zone_load_balancing_enabled = optional(bool, false) + security_group_ids = optional(list(string), []) + access_logs_enabled = optional(bool, false) + access_logs_bucket_arn = optional(string) + elastic_ips_enabled = optional(bool, false) + elastic_ip_allocation_ids = optional(list(string), []) + })) + description = "Public (internet-facing) Network Load Balancers. Each entry creates one NLB with its own configuration. When name is null, the first entry is named '-pub-nlb' and later entries '-pub-nlb-'." default = [] validation { - condition = alltrue([for sg in var.public_nlb_security_group_ids : can(regex("^sg-", sg))]) - error_message = "All public_nlb_security_group_ids must be valid security group IDs starting with 'sg-'." + condition = alltrue([for lb in var.public_nlbs : alltrue([for sg in lb.security_group_ids : can(regex("^sg-", sg))])]) + error_message = "All public_nlbs security_group_ids must be valid security group IDs starting with 'sg-'." } -} - -variable "public_nlb_access_logs_enabled" { - type = bool - description = "Enable access logging for the public NLB." - default = false -} - -variable "public_nlb_access_logs_bucket_arn" { - type = string - description = "The ARN of an existing S3 bucket for public NLB access logs." - default = null validation { - condition = var.public_nlb_access_logs_bucket_arn == null || can(regex("^arn:aws:s3:::", var.public_nlb_access_logs_bucket_arn)) - error_message = "The public_nlb_access_logs_bucket_arn must be a valid S3 bucket ARN." + condition = alltrue([for lb in var.public_nlbs : lb.access_logs_bucket_arn == null || can(regex("^arn:aws:s3:::", coalesce(lb.access_logs_bucket_arn, "invalid")))]) + error_message = "Each public_nlbs access_logs_bucket_arn must be a valid S3 bucket ARN." } -} - -variable "public_nlb_elastic_ips_enabled" { - type = bool - description = "Enable static IP addresses for the public NLB using Elastic IPs." - default = false -} - -variable "public_nlb_elastic_ip_allocation_ids" { - type = list(string) - description = "A list of Elastic IP allocation IDs for the public NLB, one per subnet." - default = [] validation { - condition = alltrue([for eip in var.public_nlb_elastic_ip_allocation_ids : can(regex("^eipalloc-", eip))]) - error_message = "All public_nlb_elastic_ip_allocation_ids must be valid Elastic IP allocation IDs starting with 'eipalloc-'." + condition = alltrue([for lb in var.public_nlbs : alltrue([for eip in lb.elastic_ip_allocation_ids : can(regex("^eipalloc-", eip))])]) + error_message = "All public_nlbs elastic_ip_allocation_ids must be valid Elastic IP allocation IDs starting with 'eipalloc-'." } -} -################################################################################ -# Private NLB -################################################################################ - -variable "private_nlb_enabled" { - type = bool - description = "Enable a private (internal) Network Load Balancer." - default = false -} + validation { + condition = alltrue([ + for idx, lb in var.public_nlbs : + length(coalesce(lb.name, idx == 0 ? "${var.name}-pub-nlb" : "${var.name}-pub-nlb-${idx + 1}")) <= 32 + ]) + error_message = "Each public NLB name (explicit or derived from the module name) must be 32 characters or less." + } -variable "private_nlb_cross_zone_load_balancing_enabled" { - type = bool - description = "Enable cross-zone load balancing for the private NLB." - default = false + validation { + condition = length(distinct([ + for idx, lb in var.public_nlbs : + coalesce(lb.name, idx == 0 ? "${var.name}-pub-nlb" : "${var.name}-pub-nlb-${idx + 1}") + ])) == length(var.public_nlbs) + error_message = "Each public NLB must have a unique name." + } } -variable "private_nlb_security_group_ids" { - type = list(string) - description = "A list of security group IDs to attach to the private NLB." +variable "private_nlbs" { + type = list(object({ + name = optional(string) + cross_zone_load_balancing_enabled = optional(bool, false) + security_group_ids = optional(list(string), []) + access_logs_enabled = optional(bool, false) + access_logs_bucket_arn = optional(string) + elastic_ips_enabled = optional(bool, false) + elastic_ip_allocation_ids = optional(list(string), []) + })) + description = "Private (internal) Network Load Balancers. Each entry creates one NLB with its own configuration. When name is null, the first entry is named '-priv-nlb' and later entries '-priv-nlb-'." default = [] validation { - condition = alltrue([for sg in var.private_nlb_security_group_ids : can(regex("^sg-", sg))]) - error_message = "All private_nlb_security_group_ids must be valid security group IDs starting with 'sg-'." + condition = alltrue([for lb in var.private_nlbs : alltrue([for sg in lb.security_group_ids : can(regex("^sg-", sg))])]) + error_message = "All private_nlbs security_group_ids must be valid security group IDs starting with 'sg-'." } -} - -variable "private_nlb_access_logs_enabled" { - type = bool - description = "Enable access logging for the private NLB." - default = false -} - -variable "private_nlb_access_logs_bucket_arn" { - type = string - description = "The ARN of an existing S3 bucket for private NLB access logs." - default = null validation { - condition = var.private_nlb_access_logs_bucket_arn == null || can(regex("^arn:aws:s3:::", var.private_nlb_access_logs_bucket_arn)) - error_message = "The private_nlb_access_logs_bucket_arn must be a valid S3 bucket ARN." + condition = alltrue([for lb in var.private_nlbs : lb.access_logs_bucket_arn == null || can(regex("^arn:aws:s3:::", coalesce(lb.access_logs_bucket_arn, "invalid")))]) + error_message = "Each private_nlbs access_logs_bucket_arn must be a valid S3 bucket ARN." } -} -variable "private_nlb_elastic_ips_enabled" { - type = bool - description = "Enable static IP addresses for the private NLB using Elastic IPs." - default = false -} + validation { + condition = alltrue([for lb in var.private_nlbs : alltrue([for eip in lb.elastic_ip_allocation_ids : can(regex("^eipalloc-", eip))])]) + error_message = "All private_nlbs elastic_ip_allocation_ids must be valid Elastic IP allocation IDs starting with 'eipalloc-'." + } -variable "private_nlb_elastic_ip_allocation_ids" { - type = list(string) - description = "A list of Elastic IP allocation IDs for the private NLB, one per subnet." - default = [] + validation { + condition = alltrue([ + for idx, lb in var.private_nlbs : + length(coalesce(lb.name, idx == 0 ? "${var.name}-priv-nlb" : "${var.name}-priv-nlb-${idx + 1}")) <= 32 + ]) + error_message = "Each private NLB name (explicit or derived from the module name) must be 32 characters or less." + } validation { - condition = alltrue([for eip in var.private_nlb_elastic_ip_allocation_ids : can(regex("^eipalloc-", eip))]) - error_message = "All private_nlb_elastic_ip_allocation_ids must be valid Elastic IP allocation IDs starting with 'eipalloc-'." + condition = length(distinct([ + for idx, lb in var.private_nlbs : + coalesce(lb.name, idx == 0 ? "${var.name}-priv-nlb" : "${var.name}-priv-nlb-${idx + 1}") + ])) == length(var.private_nlbs) + error_message = "Each private NLB must have a unique name." } } diff --git a/compute/ecs_service/rvn-ecs-nlb-definition.yml b/compute/ecs_service/rvn-ecs-nlb-definition.yml index 8aebf04b..e3b1d21f 100644 --- a/compute/ecs_service/rvn-ecs-nlb-definition.yml +++ b/compute/ecs_service/rvn-ecs-nlb-definition.yml @@ -3,8 +3,12 @@ definition: name: ECS Network Service description: Network Load Balanced ECS service for running TCP, UDP, or TLS workloads behind an ECS cluster Network Load Balancer. release: - version: 1.0.0 - description: First stable release. + version: 2.0.0 + description: >- + Support ECS clusters with multiple public and private network load balancers. + Adds public/private NLB selectors under the cluster reference; the service uses + the selected NLB's ARN and security group, and defaults to the cluster's first + NLB when none is selected. module: inputs: - id: section_cluster @@ -294,14 +298,24 @@ module: << module.input.public_nlb_service_enabled ? module.input.load_balancer_ingress_cidr_blocks : module.input.private_load_balancer_ingress_cidr_blocks >> load_balancer_security_group_id: >- - << module.input.public_nlb_service_enabled ? module.input.public_nlb_security_group_id : - module.input.private_nlb_security_group_id >> + << module.input.public_nlb_service_enabled ? + ((module.input.public_nlb_name || "") == "" ? + module.input.public_nlb_security_group_id : + get(get(module.input.public_nlbs_by_name, module.input.public_nlb_name), "security_group_id")) : + ((module.input.private_nlb_name || "") == "" ? + module.input.private_nlb_security_group_id : + get(get(module.input.private_nlbs_by_name, module.input.private_nlb_name), "security_group_id")) >> load_balancer_attachment: container_port: << module.input.listeners[0].container_port >> enabled: true nlb_listeners: >- << map(module.input.listeners, {"nlb_arn": (module.input.public_nlb_service_enabled ? - module.input.public_nlb_arn : module.input.private_nlb_arn), "port": #.listener_port, + ((module.input.public_nlb_name || "") == "" ? + module.input.public_nlb_arn : + get(get(module.input.public_nlbs_by_name, module.input.public_nlb_name), "arn")) : + ((module.input.private_nlb_name || "") == "" ? + module.input.private_nlb_arn : + get(get(module.input.private_nlbs_by_name, module.input.private_nlb_name), "arn"))), "port": #.listener_port, "protocol": #.listener_protocol, "container_port": #.container_port, "target_protocol": (#.listener_protocol == "TLS" ? #.tls_target_protocol : #.listener_protocol), "certificate_arn": (#.listener_protocol == "TLS" ? #.tls_certificate_arn : nil), @@ -424,8 +438,13 @@ module: dimensions: TargetGroup: << stack.output.target_group_arn_suffix >> LoadBalancer: >- - << module.input.public_nlb_service_enabled ? module.input.public_nlb_arn_suffix : - module.input.private_nlb_arn_suffix >> + << module.input.public_nlb_service_enabled ? + ((module.input.public_nlb_name || "") == "" ? + module.input.public_nlb_arn_suffix : + get(get(module.input.public_nlbs_by_name, module.input.public_nlb_name), "arn_suffix")) : + ((module.input.private_nlb_name || "") == "" ? + module.input.private_nlb_arn_suffix : + get(get(module.input.private_nlbs_by_name, module.input.private_nlb_name), "arn_suffix")) >> name: HealthyHostCount namespace: AWS/NetworkELB region: << stack.output.region >> @@ -439,8 +458,13 @@ module: dimensions: TargetGroup: << stack.output.target_group_arn_suffix >> LoadBalancer: >- - << module.input.public_nlb_service_enabled ? module.input.public_nlb_arn_suffix : - module.input.private_nlb_arn_suffix >> + << module.input.public_nlb_service_enabled ? + ((module.input.public_nlb_name || "") == "" ? + module.input.public_nlb_arn_suffix : + get(get(module.input.public_nlbs_by_name, module.input.public_nlb_name), "arn_suffix")) : + ((module.input.private_nlb_name || "") == "" ? + module.input.private_nlb_arn_suffix : + get(get(module.input.private_nlbs_by_name, module.input.private_nlb_name), "arn_suffix")) >> name: UnHealthyHostCount namespace: AWS/NetworkELB region: << stack.output.region >> diff --git a/compute/ecs_service/rvn-ecs-web-definition.yml b/compute/ecs_service/rvn-ecs-web-definition.yml index 7385a3e8..e06a323d 100644 --- a/compute/ecs_service/rvn-ecs-web-definition.yml +++ b/compute/ecs_service/rvn-ecs-web-definition.yml @@ -3,11 +3,12 @@ definition: name: ECS Web Service description: Web server ECS service for running an HTTP application behind an ECS cluster load balancer. release: - version: 1.0.0 + version: 2.0.0 description: >- - First stable release. Assign ALB listener rule priorities at apply time when - no explicit priority is set, so multiple services can share a listener - without priority conflicts. + Support ECS clusters with multiple public and private load balancers. Adds + public/private load balancer selectors under the cluster reference; the + service uses the selected load balancer's listeners and security group, and + defaults to the cluster's first load balancer when none is selected. module: inputs: - id: section_cluster @@ -20,16 +21,22 @@ module: mapped_inputs: - $include: ../../partials/inputs/ecs-service-cluster-common-mapped-inputs.yml - $include: ../../partials/inputs/ecs-service-cluster-alb-mapped-inputs.yml - - default: <> - id: public_alb_arn_suffix - immutable: true - label: Public ALB ARN suffix + - id: public_alb_name + label: Public load balancer type: string - - default: <> - id: private_alb_arn_suffix - immutable: true - label: Private ALB ARN suffix + description: Which of the cluster's public load balancers this service uses. Defaults to the first one when unset; a name that no longer exists on the cluster fails the deployment instead of silently using another load balancer. + required: false + show_when: + public_web_service_enabled: true + values: $values:ravion/module_input_options?module_type=rvn-ecs-cluster&module_instance_id=<>&input=public_albs&value_field=name + - id: private_alb_name + label: Private load balancer type: string + description: Which of the cluster's private load balancers this service uses. Defaults to the first one when unset; a name that no longer exists on the cluster fails the deployment instead of silently using another load balancer. + required: false + show_when: + public_web_service_enabled: false + values: $values:ravion/module_input_options?module_type=rvn-ecs-cluster&module_instance_id=<>&input=private_albs&value_field=name required: true - id: section_service label: Web service @@ -333,10 +340,15 @@ module: attachment_control: enabled: true listener_arn: >- - << module.input.public_web_service_enabled ? (module.input.public_alb_https_listener_arn || - module.input.public_alb_http_listener_arn) : - (module.input.private_alb_https_listener_arn || - module.input.private_alb_http_listener_arn) >> + << module.input.public_web_service_enabled ? + ((module.input.public_alb_name || "") == "" ? + (module.input.public_alb_https_listener_arn || module.input.public_alb_http_listener_arn) : + (get(get(module.input.public_albs_by_name, module.input.public_alb_name), "https_listener_arn") || + get(get(module.input.public_albs_by_name, module.input.public_alb_name), "http_listener_arn"))) : + ((module.input.private_alb_name || "") == "" ? + (module.input.private_alb_https_listener_arn || module.input.private_alb_http_listener_arn) : + (get(get(module.input.private_albs_by_name, module.input.private_alb_name), "https_listener_arn") || + get(get(module.input.private_albs_by_name, module.input.private_alb_name), "http_listener_arn"))) >> target_group: health_check: enabled: true @@ -359,8 +371,13 @@ module: type: '<< module.input.target_group_stickiness_enabled ? module.input.target_group_stickiness_type : "lb_cookie" >>' target_type: ip load_balancer_security_group_id: >- - << module.input.public_web_service_enabled ? module.input.public_alb_security_group_id : - module.input.private_alb_security_group_id >> + << module.input.public_web_service_enabled ? + ((module.input.public_alb_name || "") == "" ? + module.input.public_alb_security_group_id : + get(get(module.input.public_albs_by_name, module.input.public_alb_name), "security_group_id")) : + ((module.input.private_alb_name || "") == "" ? + module.input.private_alb_security_group_id : + get(get(module.input.private_albs_by_name, module.input.private_alb_name), "security_group_id")) >> tool: opentofu tool_version: << module.input.opentofu_version || defaults.opentofu_version >> variant: standard @@ -497,8 +514,13 @@ module: dimensions: TargetGroup: << stack.output.target_group_arn_suffix >> LoadBalancer: >- - << module.input.public_web_service_enabled ? module.input.public_alb_arn_suffix : - module.input.private_alb_arn_suffix >> + << module.input.public_web_service_enabled ? + ((module.input.public_alb_name || "") == "" ? + module.input.public_alb_arn_suffix : + get(get(module.input.public_albs_by_name, module.input.public_alb_name), "arn_suffix")) : + ((module.input.private_alb_name || "") == "" ? + module.input.private_alb_arn_suffix : + get(get(module.input.private_albs_by_name, module.input.private_alb_name), "arn_suffix")) >> name: RequestCount namespace: AWS/ApplicationELB region: << stack.output.region >> @@ -512,8 +534,13 @@ module: dimensions: TargetGroup: << stack.output.target_group_arn_suffix >> LoadBalancer: >- - << module.input.public_web_service_enabled ? module.input.public_alb_arn_suffix : - module.input.private_alb_arn_suffix >> + << module.input.public_web_service_enabled ? + ((module.input.public_alb_name || "") == "" ? + module.input.public_alb_arn_suffix : + get(get(module.input.public_albs_by_name, module.input.public_alb_name), "arn_suffix")) : + ((module.input.private_alb_name || "") == "" ? + module.input.private_alb_arn_suffix : + get(get(module.input.private_albs_by_name, module.input.private_alb_name), "arn_suffix")) >> name: HTTPCode_Target_5XX_Count namespace: AWS/ApplicationELB region: << stack.output.region >> @@ -527,8 +554,13 @@ module: dimensions: TargetGroup: << stack.output.target_group_arn_suffix >> LoadBalancer: >- - << module.input.public_web_service_enabled ? module.input.public_alb_arn_suffix : - module.input.private_alb_arn_suffix >> + << module.input.public_web_service_enabled ? + ((module.input.public_alb_name || "") == "" ? + module.input.public_alb_arn_suffix : + get(get(module.input.public_albs_by_name, module.input.public_alb_name), "arn_suffix")) : + ((module.input.private_alb_name || "") == "" ? + module.input.private_alb_arn_suffix : + get(get(module.input.private_albs_by_name, module.input.private_alb_name), "arn_suffix")) >> name: HTTPCode_Target_4XX_Count namespace: AWS/ApplicationELB region: << stack.output.region >> @@ -542,8 +574,13 @@ module: dimensions: TargetGroup: << stack.output.target_group_arn_suffix >> LoadBalancer: >- - << module.input.public_web_service_enabled ? module.input.public_alb_arn_suffix : - module.input.private_alb_arn_suffix >> + << module.input.public_web_service_enabled ? + ((module.input.public_alb_name || "") == "" ? + module.input.public_alb_arn_suffix : + get(get(module.input.public_albs_by_name, module.input.public_alb_name), "arn_suffix")) : + ((module.input.private_alb_name || "") == "" ? + module.input.private_alb_arn_suffix : + get(get(module.input.private_albs_by_name, module.input.private_alb_name), "arn_suffix")) >> name: TargetResponseTime namespace: AWS/ApplicationELB region: << stack.output.region >> @@ -557,8 +594,13 @@ module: dimensions: TargetGroup: << stack.output.target_group_arn_suffix >> LoadBalancer: >- - << module.input.public_web_service_enabled ? module.input.public_alb_arn_suffix : - module.input.private_alb_arn_suffix >> + << module.input.public_web_service_enabled ? + ((module.input.public_alb_name || "") == "" ? + module.input.public_alb_arn_suffix : + get(get(module.input.public_albs_by_name, module.input.public_alb_name), "arn_suffix")) : + ((module.input.private_alb_name || "") == "" ? + module.input.private_alb_arn_suffix : + get(get(module.input.private_albs_by_name, module.input.private_alb_name), "arn_suffix")) >> name: HealthyHostCount namespace: AWS/ApplicationELB region: << stack.output.region >> @@ -572,8 +614,13 @@ module: dimensions: TargetGroup: << stack.output.target_group_arn_suffix >> LoadBalancer: >- - << module.input.public_web_service_enabled ? module.input.public_alb_arn_suffix : - module.input.private_alb_arn_suffix >> + << module.input.public_web_service_enabled ? + ((module.input.public_alb_name || "") == "" ? + module.input.public_alb_arn_suffix : + get(get(module.input.public_albs_by_name, module.input.public_alb_name), "arn_suffix")) : + ((module.input.private_alb_name || "") == "" ? + module.input.private_alb_arn_suffix : + get(get(module.input.private_albs_by_name, module.input.private_alb_name), "arn_suffix")) >> name: UnHealthyHostCount namespace: AWS/ApplicationELB region: << stack.output.region >> diff --git a/partials/inputs/ecs-service-cluster-alb-mapped-inputs.yml b/partials/inputs/ecs-service-cluster-alb-mapped-inputs.yml index c741d2e7..db084c90 100644 --- a/partials/inputs/ecs-service-cluster-alb-mapped-inputs.yml +++ b/partials/inputs/ecs-service-cluster-alb-mapped-inputs.yml @@ -1,16 +1,33 @@ - id: section_cluster_alb label: Cluster load balancers type: section +- id: public_albs_by_name + label: Public load balancers by name + type: object + description: Public load balancer records from the selected cluster keyed by name, including listener ARNs and security group IDs. + collapsible: true + required: false + default: <> +- id: private_albs_by_name + label: Private load balancers by name + type: object + description: Private load balancer records from the selected cluster keyed by name, including listener ARNs and security group IDs. + collapsible: true + required: false + default: <> - id: public_alb_http_listener_arn immutable: true label: Public ALB HTTP listener ARN type: string + description: HTTP listener ARN from the cluster's first public ALB. + collapsible: true + required: false default: <> - id: public_alb_https_listener_arn immutable: true label: Public ALB HTTPS listener ARN type: string - description: HTTPS listener ARN from the selected public ALB. Ravion uses it when present, otherwise falls back to HTTP. + description: HTTPS listener ARN from the cluster's first public ALB. Ravion uses it when present, otherwise falls back to HTTP. collapsible: true required: false default: <> @@ -18,17 +35,31 @@ immutable: true label: Public ALB security group ID type: string + description: Security group ID of the cluster's first public ALB. + collapsible: true + required: false default: <> +- id: public_alb_arn_suffix + immutable: true + label: Public ALB ARN suffix + type: string + description: ARN suffix of the cluster's first public ALB for CloudWatch metrics. + collapsible: true + required: false + default: <> - id: private_alb_http_listener_arn immutable: true label: Private ALB HTTP listener ARN type: string + description: HTTP listener ARN from the cluster's first private ALB. + collapsible: true + required: false default: <> - id: private_alb_https_listener_arn immutable: true label: Private ALB HTTPS listener ARN type: string - description: HTTPS listener ARN from the selected private ALB. Ravion uses it when present, otherwise falls back to HTTP. + description: HTTPS listener ARN from the cluster's first private ALB. Ravion uses it when present, otherwise falls back to HTTP. collapsible: true required: false default: <> @@ -36,4 +67,15 @@ immutable: true label: Private ALB security group ID type: string + description: Security group ID of the cluster's first private ALB. + collapsible: true + required: false default: <> +- id: private_alb_arn_suffix + immutable: true + label: Private ALB ARN suffix + type: string + description: ARN suffix of the cluster's first private ALB for CloudWatch metrics. + collapsible: true + required: false + default: <> diff --git a/partials/inputs/ecs-service-cluster-nlb-mapped-inputs.yml b/partials/inputs/ecs-service-cluster-nlb-mapped-inputs.yml index f73d5f6e..84a3385a 100644 --- a/partials/inputs/ecs-service-cluster-nlb-mapped-inputs.yml +++ b/partials/inputs/ecs-service-cluster-nlb-mapped-inputs.yml @@ -1,33 +1,81 @@ - id: section_cluster_nlb label: Cluster network load balancers type: section +- id: public_nlb_name + label: Public NLB + type: string + description: Which of the cluster's public network load balancers this service uses. Defaults to the first one when unset; a name that no longer exists on the cluster fails the deployment instead of silently using another load balancer. + required: false + show_when: + public_nlb_service_enabled: true + values: $values:ravion/module_input_options?module_type=rvn-ecs-cluster&module_instance_id=<>&input=public_nlbs&value_field=name +- id: private_nlb_name + label: Private NLB + type: string + description: Which of the cluster's private network load balancers this service uses. Defaults to the first one when unset; a name that no longer exists on the cluster fails the deployment instead of silently using another load balancer. + required: false + show_when: + public_nlb_service_enabled: false + values: $values:ravion/module_input_options?module_type=rvn-ecs-cluster&module_instance_id=<>&input=private_nlbs&value_field=name +- id: public_nlbs_by_name + label: Public NLBs by name + type: object + description: Public network load balancer records from the selected cluster keyed by name, including ARNs and security group IDs. + collapsible: true + required: false + default: <> +- id: private_nlbs_by_name + label: Private NLBs by name + type: object + description: Private network load balancer records from the selected cluster keyed by name, including ARNs and security group IDs. + collapsible: true + required: false + default: <> - id: public_nlb_arn immutable: true label: Public NLB ARN type: string + description: ARN of the cluster's first public NLB. + collapsible: true + required: false default: <> -- id: public_nlb_arn_suffix - immutable: true - label: Public NLB ARN suffix - type: string - default: <> - id: public_nlb_security_group_id immutable: true label: Public NLB security group ID type: string + description: Security group ID of the cluster's first public NLB. + collapsible: true + required: false default: <> +- id: public_nlb_arn_suffix + immutable: true + label: Public NLB ARN suffix + type: string + description: ARN suffix of the cluster's first public NLB for CloudWatch metrics. + collapsible: true + required: false + default: <> - id: private_nlb_arn immutable: true label: Private NLB ARN type: string + description: ARN of the cluster's first private NLB. + collapsible: true + required: false default: <> -- id: private_nlb_arn_suffix - immutable: true - label: Private NLB ARN suffix - type: string - default: <> - id: private_nlb_security_group_id immutable: true label: Private NLB security group ID type: string + description: Security group ID of the cluster's first private NLB. + collapsible: true + required: false default: <> +- id: private_nlb_arn_suffix + immutable: true + label: Private NLB ARN suffix + type: string + description: ARN suffix of the cluster's first private NLB for CloudWatch metrics. + collapsible: true + required: false + default: <> diff --git a/tools/ravion-modules/test/compiler.test.ts b/tools/ravion-modules/test/compiler.test.ts index 55822d7f..1248919b 100644 --- a/tools/ravion-modules/test/compiler.test.ts +++ b/tools/ravion-modules/test/compiler.test.ts @@ -247,32 +247,39 @@ describe("compiler", () => { assert.deepEqual(additionalCertificateArns.show_when, { https_listener_enabled: true }); const clusterInputs = getModuleInputs(cluster.module); - for (const [inputId, mappedInputId, additionalInputId] of [ - ["public_alb_certificate", "public_alb_certificate_arns", "public_alb_additional_certificate_arns"], - ["private_alb_certificate", "private_alb_certificate_arns", "private_alb_additional_certificate_arns"], - ]) { - const clusterCertificate = findInput(clusterInputs, inputId); - assert.equal(clusterCertificate.type, "$ref:rvn-acm-certificate"); - const mappedInputs = clusterCertificate.mapped_inputs; - assert.ok(Array.isArray(mappedInputs), `${inputId}.mapped_inputs should be an array`); - const mappedInput = assertRecord(mappedInputs[0], `${inputId} ARN mapped input`); - assert.equal(mappedInput.id, mappedInputId); + for (const arrayInputId of ["public_albs", "private_albs"]) { + const albArray = findInput(clusterInputs, arrayInputId); + assert.equal(albArray.type, "object_array"); + const itemInputs = albArray.item_inputs; + assert.ok(Array.isArray(itemInputs), `${arrayInputId}.item_inputs should be an array`); + const certificate = assertRecord( + itemInputs.find((item) => assertRecord(item, "item input").id === "certificate"), + `${arrayInputId} certificate item input`, + ); + assert.equal(certificate.type, "$ref:rvn-acm-certificate"); + const mappedInputs = certificate.mapped_inputs; + assert.ok(Array.isArray(mappedInputs), `${arrayInputId} certificate.mapped_inputs should be an array`); + const mappedInput = assertRecord(mappedInputs[0], `${arrayInputId} certificate ARN mapped input`); + assert.equal(mappedInput.id, "certificate_arns"); assert.equal(mappedInput.type, "string_array"); - assert.deepEqual(findInput(clusterInputs, additionalInputId).default, []); + const additionalCertificates = assertRecord( + itemInputs.find( + (item) => assertRecord(item, "item input").id === "additional_certificate_arns", + ), + `${arrayInputId} additional certificate ARNs item input`, + ); + assert.equal(additionalCertificates.type, "string_array"); } assert.match( assertString(getTerraformVariable(alb.module, "certificate_arns")), /concat\(module\.input\.additional_certificate_arns/, ); - assert.match( - assertString(getTerraformVariable(cluster.module, "public_alb_certificate_arns")), - /concat\(module\.input\.public_alb_additional_certificate_arns/, - ); - assert.match( - assertString(getTerraformVariable(cluster.module, "private_alb_certificate_arns")), - /concat\(module\.input\.private_alb_additional_certificate_arns/, - ); + for (const arrayInputId of ["public_albs", "private_albs"]) { + const mapping = assertString(getTerraformVariable(cluster.module, arrayInputId)); + assert.match(mapping, /certificate_arns/); + assert.match(mapping, /additional_certificate_arns/); + } const ipv4Ingress = assertString(getTerraformVariable(alb.module, "ingress_cidr_blocks")); assert.match(ipv4Ingress, /ingress_cidr_blocks != nil/);