diff --git a/.github/workflows/terraform-static-checks.yml b/.github/workflows/terraform-static-checks.yml index d8c46269..0a21d369 100644 --- a/.github/workflows/terraform-static-checks.yml +++ b/.github/workflows/terraform-static-checks.yml @@ -39,7 +39,7 @@ jobs: run: | set -euo pipefail - modules=$(find . -mindepth 2 -maxdepth 2 -name versions.tf -exec dirname {} \; | sort) + modules=$(find . -mindepth 3 -maxdepth 3 -name versions.tf -exec dirname {} \; | sort) for module in $modules; do echo "Validating ${module}" @@ -52,6 +52,13 @@ jobs: tofu -chdir=cdn/cloudfront init -backend=false tofu -chdir=cdn/cloudfront test + - name: Test ECS managed domains + run: | + tofu -chdir=compute/ecs_cluster init -backend=false + tofu -chdir=compute/ecs_cluster test -filter=tests/managed_domains.tftest.hcl + tofu -chdir=compute/ecs_service init -backend=false + tofu -chdir=compute/ecs_service test -filter=tests/managed_domains.tftest.hcl + - name: Test CloudFront redirect function run: node --test cdn/cloudfront/functions/redirect.test.js diff --git a/compute/autoscaling/outputs.tf b/compute/autoscaling/outputs.tf index 2c46bb89..ea0b53c8 100644 --- a/compute/autoscaling/outputs.tf +++ b/compute/autoscaling/outputs.tf @@ -152,3 +152,13 @@ output "region" { description = "The AWS region where the resources are deployed." value = local.region } + +output "mixed_instances_policy_enabled" { + description = "Whether the ASG uses a mixed-instances (spot) policy." + value = length(aws_autoscaling_group.this.mixed_instances_policy) > 0 +} + +output "spot_instances_distribution" { + description = "The instances_distribution of the mixed-instances policy, null when spot is disabled." + value = try(aws_autoscaling_group.this.mixed_instances_policy[0].instances_distribution[0], null) +} diff --git a/compute/ecs_cluster/.terraform.lock.hcl b/compute/ecs_cluster/.terraform.lock.hcl index 28ffb2fa..e5e2edeb 100644 --- a/compute/ecs_cluster/.terraform.lock.hcl +++ b/compute/ecs_cluster/.terraform.lock.hcl @@ -1,25 +1,38 @@ # This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. +provider "provider-cf.siddharthsuresh.dev/ravion/ravion" { + version = "1.0.2" + constraints = ">= 1.0.2" + hashes = [ + "h1:ccuiGjAmovmbWHM0xle7Biu4dove9If7Lt0VwZxtlYc=", + "zh:45020d14e5f0d83aeb67e573923243dba0459d04440ba6c1a345067e9d3cfddb", + "zh:763efec9222a15ea3cfc5839eec6795349c56ccb72a0fda0840d47c91ed5e8e4", + "zh:c64dcec53299d21111a3aedc624aeca04c29ce4fce63057c1f89db955263d823", + "zh:ca3096e44961db8889d96a43c4910951e62226231ee67e48469e686735f26add", + "zh:cafa818df7b15e9b9a2b12e4ae32a8ecb92286fa377cc1a89aa55f1a6c8a46d0", + ] +} + provider "registry.opentofu.org/hashicorp/aws" { - version = "6.39.0" - constraints = ">= 5.0.0" + version = "6.57.1" + constraints = ">= 6.0.0" hashes = [ - "h1:c9SG8ZdYgzqpxORpTqeLFeXW4qQQ8GMGCcUkU+FAfQM=", - "zh:00a6c0d8b5b86833087e367b632e9ab73fb8db9c43569020ebd0489dc2c919ce", - "zh:05f2b56211f4c8a0b66a093d025187cbc7be086dedef62306f5a28290598ebdc", - "zh:24d97a31d5ab814c33ed32a5b7674f1a15544b2367a95bddd00cfdd8d6b82740", - "zh:258194e24ac07ee194d580ca25a25fa7bc48fa40fed4fd58352b0a64da0da4c9", - "zh:315337e5f0ccafeadf490f117151b52c6d66244bf652f4fee975eddda662af3b", - "zh:38573dd56cca8c0ffe33396cf17cc8bd13de1d27d3c4da4177e485d174f1eaf0", - "zh:4baa806c5eb8faae95cea3f1dfafb153b5e3e96c5b30a2102072da4f032d2d9b", - "zh:4f258106baca7e00a6904b2353579d283e4400a75cd0353a25e057921e8a8d96", - "zh:62e5d4628d03883a6c2a6e3c297eb54df9b5935e9e3a655dbb1c6c5ddaf7ea33", - "zh:8af5fae01c1cef65d149fa6fe47e94cf46ffa97d29e8f2dfe41aeae01da590ea", - "zh:a8240b40f7be408ac24897597a85dc4fe56f390224b11ecad2c1327e686fca58", - "zh:c549eee2a0cf0e2c4a676614d990121b685beab0047b1073407ee26247c4be13", - "zh:cfed074ba8948c75445c74c69722cb17c960024b1917b4f26905aa9c9ac4e667", - "zh:d6f4f4fa01e33d0d546705e2776f38d0b4f2847827b3f07ecde87cc02ef3d23e", - "zh:e7239b349c3149e4670750481b687c5c828908fd09f2196d7af1ac1b4d83e80b", + "h1:C4XEcAWUKXExKCF6NhvMxkSyZ/JqNWjtwtwwEqQtl70=", + "zh:053910cce06bba506fb7a3dcecdcd719280d7df8b8a7cce06ae3e2dd20b1cc0d", + "zh:14feabeefef742b528c978a78f6896240a7ca3464a4714a5b1e1b2d448db4fd5", + "zh:22dd549e3c479b2ff2121e00a005aa3b6b43a166e727ef3208a529a59feb1608", + "zh:3ca5eab63ac77da335d42ebe0af8569c5c617dc5b239750b111f5ba9f2f889cd", + "zh:3d686f9fca9752850958a353971cd7eb1f5a48e55c2c56fcd96d18cc6e54f8ae", + "zh:512e46d35abfec0ba85fc0f2da7dc39fece9106f02165e95096b6549f920453a", + "zh:5fe83903b4bef5b7318de4b61e0ff9c88aa8ac42b7abeae83d1599cb054b0908", + "zh:61ec8a18a7270e28f43c0f810c82a147af7a5de622eb1f243c3a773e234b2172", + "zh:6428cd62e615ccaaa3a26a22f68a84f80a0d497a04f0405648119ef69e709ff9", + "zh:776e57bb2c94716f452eea5803c90b97d304c9f343e6746780a255d8217b95f9", + "zh:99bacf79ce92568d086ed62b287d9b77fa0c4e86a6c25ad398bec04ea0e44897", + "zh:b4361d4ad5074df3e83dc0e40452b63f231ae5959b4007763739d3a02caffe29", + "zh:d96c0f8603a1f8ab61c633a7a707922313f6bdfa8d17fa21d9ccf103fade6af7", + "zh:edfd955636489371e5ad1f40fc77a023d7eb7d09140342b151f0ea15451ef41b", + "zh:eeb03674754bd059fafa62b58cf4015c2c07bc25477def3c64eef487d583a63c", ] } diff --git a/compute/ecs_cluster/load_balancers.tf b/compute/ecs_cluster/load_balancers.tf index a7b40da5..cf9654e3 100644 --- a/compute/ecs_cluster/load_balancers.tf +++ b/compute/ecs_cluster/load_balancers.tf @@ -14,13 +14,18 @@ module "public_alb" { subnet_ids = var.public_subnet_ids internal_load_balancer_enabled = false - # Listener configuration + # Listener configuration. Keep listener ownership in the ALB module so + # enabling managed domains changes only the default certificate in place. http_listener_enabled = true https_listener_enabled = var.public_alb_https_enabled http_to_https_redirect_enabled = var.public_alb_https_enabled # SSL/TLS - certificate_arns = var.public_alb_certificate_arns + # Managed mode makes the Ravion wildcard the DEFAULT certificate and keeps + # every BYO certificate attached via SNI (the submodule attaches arns[1..] + # as aws_lb_listener_certificate). Toggling managed domains on therefore + # never drops TLS for hostnames served off existing BYO certificates. + certificate_arns = local.enable_ravion_domain ? concat([ravion_aws_acm_certificate.cluster[0].arn], var.public_alb_certificate_arns) : var.public_alb_certificate_arns ssl_policy = var.public_alb_ssl_policy # ALB settings @@ -55,13 +60,18 @@ module "private_alb" { subnet_ids = var.private_subnet_ids internal_load_balancer_enabled = true - # Listener configuration + # Listener configuration. Keep listener ownership in the ALB module so + # enabling managed domains changes only the default certificate in place. http_listener_enabled = true https_listener_enabled = var.private_alb_https_enabled http_to_https_redirect_enabled = var.private_alb_https_enabled # SSL/TLS - certificate_arns = var.private_alb_certificate_arns + # Managed mode makes the Ravion wildcard the DEFAULT certificate and keeps + # every BYO certificate attached via SNI (the submodule attaches arns[1..] + # as aws_lb_listener_certificate). Toggling managed domains on therefore + # never drops TLS for hostnames served off existing BYO certificates. + certificate_arns = local.enable_ravion_domain ? concat([ravion_aws_acm_certificate.cluster[0].arn], var.private_alb_certificate_arns) : var.private_alb_certificate_arns ssl_policy = var.private_alb_ssl_policy # ALB settings diff --git a/compute/ecs_cluster/outputs.tf b/compute/ecs_cluster/outputs.tf index 70a7224c..1842740f 100644 --- a/compute/ecs_cluster/outputs.tf +++ b/compute/ecs_cluster/outputs.tf @@ -250,3 +250,37 @@ output "region" { description = "The AWS region where the resources are deployed." value = local.region } + +################################################################################ +# Ravion-managed domains +################################################################################ + +output "ravion_cluster_certificate_id" { + description = "Ravion managed-certificate id for the cluster wildcard (null unless use_ravion_managed_domains)." + value = local.enable_ravion_domain ? ravion_aws_acm_certificate.cluster[0].id : null +} + +output "ravion_cluster_domain_fqdn" { + description = "Cluster wildcard apex FQDN. Pass to ecs_service as cluster_parent_fqdn." + value = local.enable_ravion_domain ? ravion_aws_acm_certificate.cluster[0].domain_name : null +} + +output "ravion_cluster_cert_arn" { + description = "ACM ARN of the cluster wildcard cert." + value = local.enable_ravion_domain ? ravion_aws_acm_certificate.cluster[0].arn : null +} + +output "ravion_aws_account_id" { + description = "Pass-through Ravion AwsAccount row id for ecs_service Mode B." + value = var.ravion_aws_account_id +} + +output "ravion_aws_region" { + description = "Pass-through Ravion cert region for ecs_service Mode B." + value = local.enable_ravion_domain ? coalesce(var.ravion_aws_region, local.region) : null +} + +output "ravion_managed_domains_enabled" { + description = "True when the cluster owns a Ravion wildcard certificate for its selected HTTPS-enabled ALB. Services read this to show/hide managed-domain fields." + value = local.enable_ravion_domain +} diff --git a/compute/ecs_cluster/ravion_domains.tf b/compute/ecs_cluster/ravion_domains.tf new file mode 100644 index 00000000..8358a3fd --- /dev/null +++ b/compute/ecs_cluster/ravion_domains.tf @@ -0,0 +1,69 @@ +################################################################################ +# Ravion-managed cluster domain (opt-in) +################################################################################ +# When var.use_ravion_managed_domains = true, Ravion issues ONE wildcard cert +# `*.-.` (+ apex). That cert becomes the default cert +# on the selected cluster ALB's existing HTTPS listener. The cert also publishes +# a `*.` ALIAS to that ALB so service auto-FQDNs (.) resolve. +# When the flag is off, this resource is absent and the listener keeps using the +# customer-supplied certificate ARNs. + +locals { + enable_ravion_domain = var.use_ravion_managed_domains + ravion_alb_count = (var.public_alb_enabled ? 1 : 0) + (var.private_alb_enabled ? 1 : 0) +} + +# Plan-time guards (wildcard-apex collision against another cluster, and +# dependents on teardown) run automatically inside the provider's ModifyPlan — +# no data source + precondition wiring needed. The allocator enforces the same +# rules server-side as an apply-time backstop. +resource "ravion_aws_acm_certificate" "cluster" { + count = local.enable_ravion_domain ? 1 : 0 + + wildcard = true + name = coalesce(var.ravion_cluster_name, var.module_instance_given_id, var.name) + module_instance_id = var.module_instance_id + aws_account_id = var.ravion_aws_account_id + aws_region = coalesce(var.ravion_aws_region, local.region) + + # Ravion publishes a *. ALIAS to this ALB so service auto-FQDNs + # (.) resolve under the cluster wildcard. Managed mode intentionally + # supports exactly one ALB because a wildcard DNS record can target only one + # load balancer. + target_dns_name = var.public_alb_enabled ? module.public_alb[0].alb_dns_name : try(module.private_alb[0].alb_dns_name, null) + target_zone_id = var.public_alb_enabled ? module.public_alb[0].alb_zone_id : try(module.private_alb[0].alb_zone_id, null) + + lifecycle { + # Rotating the cluster wildcard cert (any RequiresReplace change, e.g. a + # renamed apex) must issue the new cert and swap it onto the HTTPS + # listener(s) BEFORE the old one is torn down. Without this, terraform + # destroys the old cert first while it is still the listener's default — + # ACM returns ResourceInUse and the rotation deadlocks. create_before_destroy + # makes it new -> listener in-place swap -> delete old (now detached). + create_before_destroy = true + + # A wildcard DNS record can target only one ALB. Supporting simultaneous + # public/private managed domains requires distinct apexes and certificates. + precondition { + condition = !var.use_ravion_managed_domains || local.ravion_alb_count == 1 + error_message = "use_ravion_managed_domains requires exactly one ALB. Enable either the public ALB or the private ALB, not both. Supporting both requires separate managed apexes and certificates." + } + # HTTPS is not optional for the selected managed ALB: its existing listener + # serves the wildcard certificate as the default certificate. + precondition { + condition = ( + !var.use_ravion_managed_domains + || (var.public_alb_enabled ? var.public_alb_https_enabled : var.private_alb_https_enabled) + ) + error_message = "use_ravion_managed_domains requires HTTPS on the selected ALB. Enable public_alb_https_enabled or private_alb_https_enabled to serve the wildcard certificate." + } + precondition { + condition = !var.use_ravion_managed_domains || (var.ravion_aws_account_id != null && var.ravion_aws_account_id != "") + error_message = "ravion_aws_account_id (aws_*) is required when use_ravion_managed_domains = true." + } + precondition { + condition = !var.use_ravion_managed_domains || (var.module_instance_id != null && var.module_instance_id != "") + error_message = "module_instance_id (minst_*) is required when use_ravion_managed_domains = true. Inside a stack run the runner injects TF_VAR_module_instance_id; set it explicitly for external runs." + } + } +} diff --git a/compute/ecs_cluster/rvn-ecs-cluster-definition.yml b/compute/ecs_cluster/rvn-ecs-cluster-definition.yml index a808060b..2563f6a8 100644 --- a/compute/ecs_cluster/rvn-ecs-cluster-definition.yml +++ b/compute/ecs_cluster/rvn-ecs-cluster-definition.yml @@ -3,8 +3,8 @@ definition: name: ECS Cluster description: Production-ready AWS ECS cluster with Fargate, Fargate Spot, optional EC2 capacity, and shared load balancers. release: - version: 0.5.0 - description: "BREAKING: Rename ec2_managed_termination_protection to ec2_managed_termination_protection_enabled and ec2_managed_scaling_status to ec2_managed_scaling_enabled; both inputs now accept booleans instead of ENABLED or DISABLED strings." + version: 0.6.2 + description: "Managed domains keeps every BYO certificate attached via SNI when the Ravion wildcard takes over as the default certificate, so enabling it never drops TLS for existing custom hostnames." module: inputs: - id: network @@ -51,6 +51,25 @@ module: value: enabled - label: Disabled value: disabled + - id: section_managed_domains + label: Managed domains + type: section + - default: false + description: Have Ravion issue and manage a shared wildcard TLS certificate and DNS for this cluster. The selected ALB HTTPS listener serves the wildcard certificate, and services get automatic domains under the cluster apex. Requires exactly one HTTPS-enabled ALB. + id: use_ravion_managed_domains + label: Ravion-managed domains + type: boolean + - description: Subdomain for the cluster's wildcard domain. Defaults to the module given ID when empty. + id: ravion_cluster_name + label: Cluster subdomain + patterns: + - message: The subdomain must contain only lowercase letters, numbers, and hyphens, and start and end with a letter or number. + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + required: false + show_when: + use_ravion_managed_domains: true + suffix: ".<< platform.apex >>" + type: string - id: section_capacity label: Capacity providers type: section @@ -245,6 +264,7 @@ module: show_when: public_alb_enabled: true public_alb_https_enabled: true + use_ravion_managed_domains: false - collapsible: true description: SSL policy for public ALB HTTPS. id: public_alb_ssl_policy @@ -315,6 +335,7 @@ module: show_when: private_alb_enabled: true private_alb_https_enabled: true + use_ravion_managed_domains: false - collapsible: true description: SSL policy for private ALB HTTPS. id: private_alb_ssl_policy @@ -696,6 +717,10 @@ module: 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 >> + use_ravion_managed_domains: << module.input.use_ravion_managed_domains >> + ravion_cluster_name: << module.input.ravion_cluster_name >> + ravion_aws_account_id: << module.input.aws_account_id >> + ravion_aws_region: << module.input.aws_region >> tags: $include: ../stack/ravion-tags.yml vpc_id: << module.input.vpc_id >> diff --git a/compute/ecs_cluster/tests/basic.tftest.hcl b/compute/ecs_cluster/tests/basic.tftest.hcl index b1a84d2b..a12eb38b 100644 --- a/compute/ecs_cluster/tests/basic.tftest.hcl +++ b/compute/ecs_cluster/tests/basic.tftest.hcl @@ -113,7 +113,7 @@ mock_provider "aws" { } override_resource { - target = module.public_alb.aws_security_group.this + 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" @@ -154,7 +154,7 @@ mock_provider "aws" { } override_resource { - target = module.private_alb.aws_security_group.this + 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" @@ -170,6 +170,13 @@ mock_provider "aws" { } } +# Default (BYO) runs never create ravion_aws_acm_certificate.cluster (count = 0), but +# Terraform still configures the ravion provider because the module declares it. +# An empty mock prevents the provider's real Configure (which requires +# RAVION_API_KEY) from failing the plan. managed_domains.tftest.hcl mocks it with +# overrides because those runs actually issue the wildcard cert. +mock_provider "ravion" {} + variables { name = "test-cluster" vpc_id = "vpc-12345678" @@ -565,13 +572,16 @@ 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" + condition = length(module.ecs_instance_security_group) == 1 + error_message = "EC2 instances should get a dedicated security group" } + # The allow-all shape (ip_protocol -1) is asserted inside the + # networking/security-groups module's own coverage; from here only the + # module's outputs are addressable. 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[0].all_egress_rule_ids) >= 1 + error_message = "EC2 security group should have an allow-all egress rule" } } @@ -589,7 +599,7 @@ run "ec2_spot_disabled_by_default" { } assert { - condition = length(module.ecs_autoscaling[0].aws_autoscaling_group.this.mixed_instances_policy) == 0 + condition = module.ecs_autoscaling[0].mixed_instances_policy_enabled == false error_message = "ASG should not use mixed instances policy when Spot disabled" } } @@ -607,17 +617,17 @@ run "ec2_spot_enabled" { } assert { - condition = length(module.ecs_autoscaling[0].aws_autoscaling_group.this.mixed_instances_policy) == 1 + condition = module.ecs_autoscaling[0].mixed_instances_policy_enabled == true error_message = "ASG should use mixed instances policy when Spot enabled" } assert { - condition = module.ecs_autoscaling[0].aws_autoscaling_group.this.mixed_instances_policy[0].instances_distribution[0].on_demand_base_capacity == 1 + condition = module.ecs_autoscaling[0].spot_instances_distribution.on_demand_base_capacity == 1 error_message = "ASG should have correct on-demand base capacity" } assert { - condition = module.ecs_autoscaling[0].aws_autoscaling_group.this.mixed_instances_policy[0].instances_distribution[0].on_demand_percentage_above_base_capacity == 25 + condition = module.ecs_autoscaling[0].spot_instances_distribution.on_demand_percentage_above_base_capacity == 25 error_message = "ASG should have correct on-demand percentage above base" } } @@ -813,7 +823,7 @@ run "ec2_with_public_alb_ingress" { } assert { - condition = length(aws_vpc_security_group_ingress_rule.ecs_instance_from_public_alb) == 1 + condition = length(module.ecs_instance_security_group[0].ingress_rule_ids) == 1 error_message = "EC2 security group should have ingress rule from public ALB" } } @@ -828,7 +838,7 @@ run "ec2_with_private_alb_ingress" { } assert { - condition = length(aws_vpc_security_group_ingress_rule.ecs_instance_from_private_alb) == 1 + condition = length(module.ecs_instance_security_group[0].ingress_rule_ids) == 1 error_message = "EC2 security group should have ingress rule from private ALB" } } @@ -844,13 +854,8 @@ run "ec2_without_albs_no_ingress" { } 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" + condition = length(module.ecs_instance_security_group[0].ingress_rule_ids) == 0 + error_message = "EC2 security group should have no ALB ingress rules when both ALBs are disabled" } } @@ -954,7 +959,7 @@ run "asg_uses_private_subnets" { } assert { - condition = length(module.ecs_autoscaling[0].aws_autoscaling_group.this.vpc_zone_identifier) == 3 + condition = length(module.ecs_autoscaling[0].autoscaling_group_vpc_zone_identifier) == 3 error_message = "ASG should be deployed to all private subnets" } } diff --git a/compute/ecs_cluster/tests/capacity_provider_strategy.tftest.hcl b/compute/ecs_cluster/tests/capacity_provider_strategy.tftest.hcl index d17cc17d..ebfd0680 100644 --- a/compute/ecs_cluster/tests/capacity_provider_strategy.tftest.hcl +++ b/compute/ecs_cluster/tests/capacity_provider_strategy.tftest.hcl @@ -60,6 +60,8 @@ mock_provider "aws" { } } +mock_provider "ravion" {} + variables { name = "test-cluster" vpc_id = "vpc-12345678" diff --git a/compute/ecs_cluster/tests/managed_domains.tftest.hcl b/compute/ecs_cluster/tests/managed_domains.tftest.hcl new file mode 100644 index 00000000..0e45f860 --- /dev/null +++ b/compute/ecs_cluster/tests/managed_domains.tftest.hcl @@ -0,0 +1,250 @@ +# Managed-domain listener integration tests. +# +# The ALB child module must keep owning HTTPS listeners, SNI attachments, and +# security-group rules. Managed mode changes only the certificate list passed +# into that existing module, preserving every released Terraform address. + +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_ssm_parameter.ecs_optimized_ami + values = { + value = "ami-0123456789abcdef0" + } + } + + override_data { + target = data.aws_elb_service_account.current + values = { + arn = "arn:aws:iam::127311923021:root" + } + } + + 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.https + values = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-public-alb/1234567890123456/6543210987654321" + } + } + + 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.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.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" + } + } +} + +mock_provider "ravion" { + override_resource { + target = ravion_aws_acm_certificate.cluster + values = { + id = "cert_test" + arn = "arn:aws:acm:us-east-1:123456789012:certificate/99999999-9999-9999-9999-999999999999" + domain_name = "test-cluster-abcd.ravion.app" + status = "ISSUED" + } + } +} + +variables { + name = "test-cluster" + module_instance_id = "minst_test" + vpc_id = "vpc-12345678" + private_subnet_ids = ["subnet-private1", "subnet-private2"] + public_subnet_ids = ["subnet-public1", "subnet-public2"] +} + +run "byo_public_listener_keeps_released_address" { + command = plan + + variables { + public_alb_enabled = true + public_alb_https_enabled = true + public_alb_certificate_arns = ["arn:aws:acm:us-east-1:123456789012:certificate/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"] + } + + assert { + condition = module.public_alb[0].https_listener_arn != null + error_message = "The existing ALB child module must own the public HTTPS listener" + } + + assert { + condition = module.public_alb[0].https_listener_certificate_arn == "arn:aws:acm:us-east-1:123456789012:certificate/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + error_message = "BYO mode must keep the customer's first certificate as the listener default" + } + + assert { + condition = output.public_alb_https_listener_arn == module.public_alb[0].https_listener_arn + error_message = "The public listener output must continue to expose the child-module listener" + } +} + +run "managed_public_swaps_certificate_in_place" { + command = plan + + variables { + public_alb_enabled = true + public_alb_https_enabled = true + use_ravion_managed_domains = true + ravion_aws_account_id = "aws_testaccount" + } + + assert { + condition = length(ravion_aws_acm_certificate.cluster) == 1 + error_message = "Managed mode must create one wildcard certificate" + } + + assert { + condition = module.public_alb[0].https_listener_certificate_arn == ravion_aws_acm_certificate.cluster[0].arn + error_message = "Managed mode must swap the existing child listener to the Ravion certificate" + } + + assert { + condition = ravion_aws_acm_certificate.cluster[0].target_dns_name == module.public_alb[0].alb_dns_name + error_message = "The managed wildcard DNS target must be the selected public ALB" + } +} + +run "managed_toggle_keeps_byo_certificates_attached" { + command = plan + + variables { + public_alb_enabled = true + public_alb_https_enabled = true + use_ravion_managed_domains = true + ravion_aws_account_id = "aws_testaccount" + public_alb_certificate_arns = [ + "arn:aws:acm:us-east-1:123456789012:certificate/byo-default", + "arn:aws:acm:us-east-1:123456789012:certificate/byo-extra", + ] + } + + # The wildcard becomes the DEFAULT certificate... + assert { + condition = module.public_alb[0].https_listener_certificate_arn == ravion_aws_acm_certificate.cluster[0].arn + error_message = "Managed mode must make the Ravion wildcard the default certificate" + } + + # ...and every BYO certificate stays attached via SNI, so hostnames served + # off them keep their TLS through and after the migration. + assert { + condition = length(module.public_alb[0].additional_certificate_arns) == 2 + error_message = "Both BYO certificates must remain attached via SNI in managed mode" + } + + assert { + condition = contains(module.public_alb[0].additional_certificate_arns, "arn:aws:acm:us-east-1:123456789012:certificate/byo-default") && contains(module.public_alb[0].additional_certificate_arns, "arn:aws:acm:us-east-1:123456789012:certificate/byo-extra") + error_message = "The SNI set must contain exactly the BYO certificates" + } +} + +run "managed_private_uses_existing_listener" { + command = plan + + variables { + private_alb_enabled = true + private_alb_https_enabled = true + use_ravion_managed_domains = true + ravion_aws_account_id = "aws_testaccount" + } + + assert { + condition = module.private_alb[0].https_listener_certificate_arn == ravion_aws_acm_certificate.cluster[0].arn + error_message = "Managed mode must use the existing private ALB listener" + } + + assert { + condition = ravion_aws_acm_certificate.cluster[0].target_dns_name == module.private_alb[0].alb_dns_name + error_message = "The managed wildcard DNS target must be the selected private ALB" + } +} + +run "managed_domains_rejects_both_albs" { + command = plan + + variables { + public_alb_enabled = true + public_alb_https_enabled = true + private_alb_enabled = true + private_alb_https_enabled = true + use_ravion_managed_domains = true + ravion_aws_account_id = "aws_testaccount" + } + + expect_failures = [ravion_aws_acm_certificate.cluster] +} + +run "managed_domains_requires_https" { + command = plan + + variables { + public_alb_enabled = true + public_alb_https_enabled = false + use_ravion_managed_domains = true + ravion_aws_account_id = "aws_testaccount" + } + + expect_failures = [ravion_aws_acm_certificate.cluster] +} + +run "managed_domains_requires_an_alb" { + command = plan + + variables { + use_ravion_managed_domains = true + ravion_aws_account_id = "aws_testaccount" + } + + expect_failures = [ravion_aws_acm_certificate.cluster] +} diff --git a/compute/ecs_cluster/variables.tf b/compute/ecs_cluster/variables.tf index eb7777ed..e3120314 100644 --- a/compute/ecs_cluster/variables.tf +++ b/compute/ecs_cluster/variables.tf @@ -686,3 +686,43 @@ variable "region" { description = "AWS region. When null, the provider's configured region is used." default = null } + +################################################################################ +# Ravion-managed domains (optional) +################################################################################ + +variable "use_ravion_managed_domains" { + type = bool + description = "Allocate a Ravion-managed wildcard domain for the cluster and serve its certificate from the selected ALB's existing HTTPS listener. Requires exactly one HTTPS-enabled ALB." + default = false +} + +variable "ravion_cluster_name" { + type = string + description = "Free-form name leaf for the cluster's Ravion wildcard domain (becomes -.). Defaults to the module instance given id." + default = null +} + +variable "module_instance_given_id" { + type = string + description = "The module instance's user-facing given id (injected by the runner as TF_VAR_module_instance_given_id). Used as the default leaf for the Ravion wildcard domain." + default = null +} + +variable "module_instance_id" { + type = string + description = "The Ravion module instance id (minst_*) that owns this cluster's Ravion-managed certificate. Injected by the runner as TF_VAR_module_instance_id inside a stack run; set it explicitly for external/API-key runs. Required when use_ravion_managed_domains = true." + default = null +} + +variable "ravion_aws_account_id" { + type = string + description = "Ravion AwsAccount row id (aws_*) the wildcard ACM cert is issued in. Required when use_ravion_managed_domains = true." + default = null +} + +variable "ravion_aws_region" { + type = string + description = "AWS region the cluster wildcard cert lives in. Defaults to the module region." + default = null +} diff --git a/compute/ecs_cluster/versions.tf b/compute/ecs_cluster/versions.tf index bec739ba..3960b587 100644 --- a/compute/ecs_cluster/versions.tf +++ b/compute/ecs_cluster/versions.tf @@ -12,7 +12,11 @@ terraform { source = "hashicorp/aws" version = ">= 6.0" } + # Ravion domains provider — only exercised when + # var.use_ravion_managed_domains = true (see ravion_domains.tf). + ravion = { + source = "provider-cf.siddharthsuresh.dev/ravion/ravion" + version = ">= 1.0.2" + } } } - - diff --git a/compute/ecs_service/.terraform.lock.hcl b/compute/ecs_service/.terraform.lock.hcl index 5c17aaf4..95f234d7 100644 --- a/compute/ecs_service/.terraform.lock.hcl +++ b/compute/ecs_service/.terraform.lock.hcl @@ -1,26 +1,38 @@ -# This file is maintained automatically by "terraform init". +# This file is maintained automatically by "tofu init". # Manual edits may be lost in future updates. -provider "registry.terraform.io/hashicorp/aws" { - version = "6.50.0" +provider "provider-cf.siddharthsuresh.dev/ravion/ravion" { + version = "1.0.2" + constraints = ">= 1.0.2" + hashes = [ + "h1:ccuiGjAmovmbWHM0xle7Biu4dove9If7Lt0VwZxtlYc=", + "zh:45020d14e5f0d83aeb67e573923243dba0459d04440ba6c1a345067e9d3cfddb", + "zh:763efec9222a15ea3cfc5839eec6795349c56ccb72a0fda0840d47c91ed5e8e4", + "zh:c64dcec53299d21111a3aedc624aeca04c29ce4fce63057c1f89db955263d823", + "zh:ca3096e44961db8889d96a43c4910951e62226231ee67e48469e686735f26add", + "zh:cafa818df7b15e9b9a2b12e4ae32a8ecb92286fa377cc1a89aa55f1a6c8a46d0", + ] +} + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.57.1" constraints = ">= 6.0.0, >= 6.21.0" hashes = [ - "h1:D8uNiOpl3UkAX4zI5T47ALMiRFXTa1XfdQC+TBu3RmE=", - "zh:0072806bb262c6d86bc25b4a75750e469881144c14818afdba7b82db840e1588", - "zh:1ebc2dae335dad7a8b16a1985b69a63a14954282bb44fdba7d5103f77551ac7b", - "zh:2dab48fe8f3193b8216d578ac1e3674fa566435cc7dbce2953d55b72e31d0241", - "zh:2fc3d3029c2b7429472391ef339672e1fca8e6ff32c8a519bf3acedafa7e24fe", - "zh:38a36e64e7212f6cedac861ea4d449cce07131b3378de601bf9d49a99e000208", - "zh:3ac70758ed251ce78b7f541a5a79cc6fe56474412783ae1decef719bdd0f30bf", - "zh:4385d3903e685bddb2b8005b4eb7db89f030267d4d03c7d792d2f5e739cc874a", - "zh:4cce0760b87fbafd51f30faec2a737f4183b7c615f4a86557f7d3c893a610dc5", - "zh:4feaeed18694239b896c6415d9a1e5ef89e1da4f4ad60924aa0522adeb1f6599", - "zh:502fca2be1c95f443c3e67d0555601d1de65b4ca82d197c059e9c868360e3a0a", - "zh:57d037f6fdd045f2660909c3bdface9622d81165ce647479cba98d1f353c5eab", - "zh:5dc5a0b915c2ac5256d909458f5c8e40b35f78b3a36ea893c86624eaf6c54e37", - "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:b84c87c58a320adbb2c74a4cad03ae5aac7f2eae21db26f00fdde98c8c4d4523", - "zh:c895f1d5cbcbeff77850ac99efd36bde0048d4e909b296882331b9b9ebf48cfa", - "zh:ead82831683619124597a1f170dd31e9b293e9cf22f558cb166d5e734fcd11e4", + "h1:C4XEcAWUKXExKCF6NhvMxkSyZ/JqNWjtwtwwEqQtl70=", + "zh:053910cce06bba506fb7a3dcecdcd719280d7df8b8a7cce06ae3e2dd20b1cc0d", + "zh:14feabeefef742b528c978a78f6896240a7ca3464a4714a5b1e1b2d448db4fd5", + "zh:22dd549e3c479b2ff2121e00a005aa3b6b43a166e727ef3208a529a59feb1608", + "zh:3ca5eab63ac77da335d42ebe0af8569c5c617dc5b239750b111f5ba9f2f889cd", + "zh:3d686f9fca9752850958a353971cd7eb1f5a48e55c2c56fcd96d18cc6e54f8ae", + "zh:512e46d35abfec0ba85fc0f2da7dc39fece9106f02165e95096b6549f920453a", + "zh:5fe83903b4bef5b7318de4b61e0ff9c88aa8ac42b7abeae83d1599cb054b0908", + "zh:61ec8a18a7270e28f43c0f810c82a147af7a5de622eb1f243c3a773e234b2172", + "zh:6428cd62e615ccaaa3a26a22f68a84f80a0d497a04f0405648119ef69e709ff9", + "zh:776e57bb2c94716f452eea5803c90b97d304c9f343e6746780a255d8217b95f9", + "zh:99bacf79ce92568d086ed62b287d9b77fa0c4e86a6c25ad398bec04ea0e44897", + "zh:b4361d4ad5074df3e83dc0e40452b63f231ae5959b4007763739d3a02caffe29", + "zh:d96c0f8603a1f8ab61c633a7a707922313f6bdfa8d17fa21d9ccf103fade6af7", + "zh:edfd955636489371e5ad1f40fc77a023d7eb7d09140342b151f0ea15451ef41b", + "zh:eeb03674754bd059fafa62b58cf4015c2c07bc25477def3c64eef487d583a63c", ] } diff --git a/compute/ecs_service/ecs_service.tf b/compute/ecs_service/ecs_service.tf index 5aee4b6f..436d2d8f 100644 --- a/compute/ecs_service/ecs_service.tf +++ b/compute/ecs_service/ecs_service.tf @@ -101,10 +101,20 @@ resource "aws_ecs_service" "this" { for_each = local.traffic_shift_infrastructure_enabled ? [1] : [] content { alternate_target_group_arn = aws_lb_target_group.tg_2[0].arn + # Ravion-managed services route via the module-created rules on the + # cluster HTTPS listener, so chunk "0" is the rule the ECS deployment + # controller rewrites during native traffic-shift deploys. production_listener_rule = ( local.enable_nlb_listener ? aws_lb_listener.nlb[0].arn - : aws_lb_listener_rule.alb["0"].arn + : ( + local.ravion_managed + # Absent only when the cluster exposes no HTTPS listener, which the + # precondition below reports; null keeps that message readable + # instead of failing here with "Invalid index". + ? (length(aws_lb_listener_rule.ravion) > 0 ? aws_lb_listener_rule.ravion["0"].arn : null) + : aws_lb_listener_rule.alb["0"].arn + ) ) test_listener_rule = local.test_listener_rule_arn role_arn = aws_iam_role.ecs_infrastructure[0].arn @@ -157,6 +167,7 @@ resource "aws_ecs_service" "this" { aws_iam_role_policy_attachment.execution_base, aws_iam_role_policy_attachment.ecs_infrastructure_elb, aws_lb_listener_rule.alb, + aws_lb_listener_rule.ravion, aws_lb_listener_rule.test, aws_lb_listener.nlb_additional, ] @@ -179,22 +190,43 @@ resource "aws_ecs_service" "this" { condition = ( !local.enable_load_balancer || local.enable_nlb_listener + || local.ravion_managed || length(var.load_balancer_attachment.listener_rules) > 0 ) - error_message = "load_balancer_attachment requires either listener_rules for ALB or nlb_listeners for NLB." + error_message = "load_balancer_attachment requires either listener_rules for ALB or nlb_listeners for NLB. (A Ravion-managed service creates its own listener rules from its domains.)" + } + + # Managed mode routes every hostname via module-created rules on the + # cluster HTTPS listener; without that listener's ARN no rule (and no + # production rule for advanced_configuration) can exist, and every + # request would fall through to the listener's fixed-response 404. + precondition { + condition = ( + !local.ravion_managed + || !local.enable_load_balancer + || local.enable_nlb_listener + || var.cluster_https_listener_arn != null + ) + error_message = "A Ravion-managed ALB service (cluster_parent_fqdn set) requires cluster_https_listener_arn (the cluster's HTTPS listener) so its hostname listener rules can be created." } # The ECS advanced_configuration API accepts a single production # listener rule, so during native traffic-shift deployments only the # first rule is rewritten — any additional rules would keep - # forwarding to the old revision for the entire deployment. + # forwarding to the old revision for the entire deployment. For a + # Ravion-managed service the rules are the module-created host-header + # chunks, so the same constraint caps it at one chunk (<=5 hostnames). precondition { condition = ( !local.is_native_traffic_shift || local.enable_nlb_listener - || length(try(var.load_balancer_attachment.listener_rules, [])) <= 1 + || ( + local.ravion_managed + ? length(local.ravion_host_header_chunks) <= 1 + : length(try(var.load_balancer_attachment.listener_rules, [])) <= 1 + ) ) - error_message = "Native traffic-shift strategies (blue_green/linear/canary) rewrite a single production listener rule; additional listener rules would keep serving the old revision throughout the deployment. Use at most one listener rule with these strategies." + error_message = "Native traffic-shift strategies (blue_green/linear/canary) rewrite a single production listener rule; additional listener rules would keep serving the old revision throughout the deployment. Use at most one listener rule with these strategies (for a Ravion-managed service: at most 5 hostnames, so all fit one rule)." } precondition { diff --git a/compute/ecs_service/listener_rules.tf b/compute/ecs_service/listener_rules.tf index 5601a799..1c82e815 100644 --- a/compute/ecs_service/listener_rules.tf +++ b/compute/ecs_service/listener_rules.tf @@ -8,7 +8,10 @@ ################################################################################ resource "aws_lb_listener_rule" "alb" { - for_each = local.enable_load_balancer ? { + # In Ravion-managed mode, Ravion owns the listener rule (ravion_domains.tf); + # caller-supplied rules are skipped to avoid priority collisions on the + # shared listener. + for_each = local.enable_load_balancer && !local.ravion_managed ? { for idx, rule in var.load_balancer_attachment.listener_rules : idx => rule } : {} diff --git a/compute/ecs_service/locals.tf b/compute/ecs_service/locals.tf index ed0f7bf1..3d9279f1 100644 --- a/compute/ecs_service/locals.tf +++ b/compute/ecs_service/locals.tf @@ -49,10 +49,14 @@ locals { # created. Drives the advanced_configuration.test_listener_rule wiring # and the TEST_TRAFFIC_SHIFT lifecycle stages on native traffic-shift # deploys. ALB-only — requires a production listener rule to mirror; a - # no-op for NLB services. + # no-op for NLB services. Suppressed for Ravion-managed services: the + # mirrored conditions come from caller listener_rules, which managed + # mode discards in favour of the module-created host-header rules, so + # the test rule would orphan on the caller's listener. green_alb_listener_rule_enabled = ( local.enable_load_balancer && !local.enable_nlb_listener + && !local.ravion_managed && var.green_alb_listener_rule_enabled && length(var.load_balancer_attachment.listener_rules) > 0 ) diff --git a/compute/ecs_service/outputs.tf b/compute/ecs_service/outputs.tf index 21a53b71..1a353d37 100644 --- a/compute/ecs_service/outputs.tf +++ b/compute/ecs_service/outputs.tf @@ -178,9 +178,16 @@ output "nlb_target_group_arns" { } output "production_listener_rule_arn" { - description = "ARN of the production ALB listener rule or primary NLB listener. The ECS deployment controller rewrites this value only for ALB traffic-shift deployments (null if load balancer disabled)." + description = "ARN of the production ALB listener rule or primary NLB listener. The ECS deployment controller rewrites this value only for ALB traffic-shift deployments (null if load balancer disabled). For a Ravion-managed service this is the first module-created host-header rule on the cluster HTTPS listener." + # The ravion rules are absent when the cluster has no HTTPS listener to put + # them on; degrade to null rather than an "Invalid index" that would bury the + # actionable cluster_https_listener_arn precondition on aws_ecs_service. value = local.enable_load_balancer ? ( - local.enable_nlb_listener ? aws_lb_listener.nlb[0].arn : aws_lb_listener_rule.alb["0"].arn + local.enable_nlb_listener ? aws_lb_listener.nlb[0].arn : ( + local.ravion_managed + ? (length(aws_lb_listener_rule.ravion) > 0 ? aws_lb_listener_rule.ravion["0"].arn : null) + : aws_lb_listener_rule.alb["0"].arn + ) ) : null } @@ -303,3 +310,22 @@ output "region" { description = "The AWS region where the resources are deployed." value = local.region } + +################################################################################ +# Ravion-managed domains +################################################################################ + +output "ravion_domain_fqdn" { + description = "Primary FQDN for this service (first entry in the domains list; the auto-FQDN under the cluster wildcard when present). Null when the cluster has no Ravion-managed domains." + value = length(local.effective_domains) > 0 ? local.effective_domains[0] : null +} + +output "ravion_domain_url" { + description = "https URL for the primary FQDN." + value = length(local.effective_domains) > 0 ? "https://${local.effective_domains[0]}" : null +} + +output "ravion_custom_cert_arn" { + description = "ACM ARN of the per-service instance cert covering the custom (non-wildcard) domains. Null when there are none." + value = length(local.custom_domains) > 0 ? ravion_aws_acm_certificate.svc[0].arn : null +} diff --git a/compute/ecs_service/ravion_domains.tf b/compute/ecs_service/ravion_domains.tf new file mode 100644 index 00000000..28ae962c --- /dev/null +++ b/compute/ecs_service/ravion_domains.tf @@ -0,0 +1,217 @@ +################################################################################ +# Ravion-managed service domains +################################################################################ +# Wired when cluster_parent_fqdn is set (piped from ecs_cluster). The `domains` +# list is the single source of truth — each entry is classified by whether the +# cluster wildcard cert covers it: +# +# - wildcard-covered (., exactly one label under the cluster apex): +# nests under the cluster wildcard cert via SNI. No per-service cert, and no +# per-domain DNS record — the cluster's `*.` ALIAS already routes it. +# - custom (anything else — external FQDNs, or names deeper than one label +# under the apex the wildcard can't cover): covered by ONE per-service +# instance ACM cert (<=10 SANs) attached to the cluster listener, plus a +# routing record the customer adds. +# +# When `domains` is empty the service still gets an auto-FQDN +# `.` (a wildcard-covered entry), so a service with no custom +# domains is reachable out of the box. + +locals { + ravion_managed = var.cluster_parent_fqdn != null && var.cluster_parent_fqdn != "" + apex = local.ravion_managed ? lower(var.cluster_parent_fqdn) : "" + + # Auto-FQDN used when the domains list is empty (matches the frontend default). + auto_fqdn = local.ravion_managed ? "${coalesce(var.module_instance_given_id, var.name)}.${local.apex}" : "" + + # The effective list: the user's domains (or the auto-FQDN when empty), + # normalized — lowercased, trailing dot + surrounding whitespace stripped, + # empties dropped. Keeps classification consistent with DNS case-insensitivity + # and the backend's lowercase sanitizeLabel. + effective_domains = local.ravion_managed ? [ + for d in(length(var.domains) > 0 ? var.domains : [local.auto_fqdn]) : + lower(trimsuffix(trimspace(d), ".")) if trimspace(d) != "" + ] : [] + + # Per-entry classification. wildcard-covered = "." with a non-empty + # single label below the apex (the only shape the `*.` cert + ALIAS + # cover). The non-empty-leaf guard keeps a malformed "." out of the + # wildcard bucket (an empty leaf would produce an invalid ALB host header). + wildcard_covered = [ + for d in local.effective_domains : d + if endswith(d, ".${local.apex}") && length(trimsuffix(d, ".${local.apex}")) > 0 && !strcontains(trimsuffix(d, ".${local.apex}"), ".") + ] + custom_domains = [ + for d in local.effective_domains : d + if !(endswith(d, ".${local.apex}") && length(trimsuffix(d, ".${local.apex}")) > 0 && !strcontains(trimsuffix(d, ".${local.apex}"), ".")) + ] + + # Domains under the cluster apex that are NOT a single-label `.`: + # the bare apex itself, or a name more than one label deep. The `*.` + # wildcard cert covers exactly one label, and the customer cannot add records + # to the Ravion-managed zone, so these can never be satisfied — they fall into + # custom_domains today and would silently emit a per-service cert + an + # unwritable routing record. Fail the plan instead (the server-side + # RejectCustomDomainUnderApex is the same backstop for direct-API callers). + invalid_apex_domains = [ + for d in local.custom_domains : d + if d == local.apex || endswith(d, ".${local.apex}") + ] + invalid_apex_domains_msg = join(", ", local.invalid_apex_domains) + + # All of this service's hostnames route to its target group. AWS ALB allows at + # most 5 values in a single rule condition, so the host headers are split into + # chunks of <=5 — one aws_lb_listener_rule per chunk (see below), each with its + # own derived priority. (chunklist([], 5) == [], handled by the rule's guard.) + ravion_host_headers = local.effective_domains + ravion_host_header_chunks = chunklist(local.ravion_host_headers, 5) + + # Base listener-rule priority. When ravion_listener_rule_priority is 0 (the + # default) it is derived from sha256(name) using 12 hex chars (~48 bits) so the + # collision probability stays low across many services sharing the cluster + # listener; mod 48000 (instead of 49000) leaves headroom below the ALB max of + # 50000 for the per-chunk offset (priority = base + chunk index). On a residual + # collision ("priority already in use") set ravion_listener_rule_priority + # explicitly to a free value. + ravion_priority = var.ravion_listener_rule_priority > 0 ? var.ravion_listener_rule_priority : ((parseint(substr(sha256(var.name), 0, 12), 16) % 48000) + 1000) + + ravion_target_group_arn = length(aws_lb_target_group.tg_1) > 0 ? aws_lb_target_group.tg_1[0].arn : null +} + +# Wildcard-covered domains (incl. the auto-FQDN): nest under the cluster +# wildcard. No per-service cert; the cluster `*.` ALIAS routes them. +# Parent-apex authorization (may this service nest under that apex?) runs +# automatically in the provider's ModifyPlan; the control plane enforces the +# same rule at apply against a signed token claim (Dns:PARENT_APEX_UNAUTHORIZED). +resource "ravion_domain" "wildcard" { + for_each = toset(local.wildcard_covered) + + name = trimsuffix(each.value, ".${local.apex}") + module_instance_id = var.module_instance_id + parent_domain_name = local.apex + + lifecycle { + precondition { + condition = var.module_instance_id != null && var.module_instance_id != "" + error_message = "module_instance_id (minst_*) is required for Ravion-managed domains. Inside a stack run the runner injects TF_VAR_module_instance_id; set it explicitly for external runs." + } + } +} + +# Per-service certificate covering the custom (non-wildcard) domains (<=10 SANs), +# attached to the cluster listener via Ravion. +resource "ravion_aws_acm_certificate" "svc" { + count = length(local.custom_domains) > 0 ? 1 : 0 + + domains = local.custom_domains + module_instance_id = var.module_instance_id + aws_account_id = var.ravion_aws_account_id + aws_region = coalesce(var.ravion_aws_region, local.region) + target_arn = var.cluster_https_listener_arn + + lifecycle { + precondition { + condition = length(local.invalid_apex_domains) == 0 + error_message = "Domains under the cluster apex must be a single label that rides the cluster wildcard, like checkout.${local.apex}. These entries are the bare apex or more than one label deep, so the wildcard certificate does not cover them and their routing record would have to live in the Ravion-managed zone (which you cannot edit): ${local.invalid_apex_domains_msg}. Use a single-label name under the apex, or a domain in a DNS zone you control." + } + precondition { + condition = length(local.custom_domains) == 0 || (var.ravion_aws_account_id != null && var.ravion_aws_account_id != "") + error_message = "ravion_aws_account_id is required when the domains list includes a custom (non-wildcard) domain." + } + precondition { + condition = length(local.custom_domains) == 0 || (var.cluster_https_listener_arn != null && var.cluster_https_listener_arn != "") + error_message = "cluster_https_listener_arn is required when the domains list includes a custom (non-wildcard) domain." + } + precondition { + condition = length(local.custom_domains) <= 10 + error_message = "A service may declare at most 10 custom (non-wildcard) domains (one cert per service)." + } + precondition { + condition = length(local.custom_domains) == 0 || (var.module_instance_id != null && var.module_instance_id != "") + error_message = "module_instance_id (minst_*) is required when the domains list includes a custom (non-wildcard) domain. Inside a stack run the runner injects TF_VAR_module_instance_id; set it explicitly for external runs." + } + } +} + +# Routing records the customer must add for each custom domain (one per FQDN). +resource "ravion_domain" "custom" { + for_each = toset(local.custom_domains) + + name = each.value + module_instance_id = var.module_instance_id + target_dns_name = var.cluster_alb_dns_name + target_zone_id = var.cluster_alb_zone_id + + lifecycle { + # Mirror of the sibling cert's cluster_https_listener_arn guard: without a + # routing target the API happily creates a domain row that never resolves — + # a silent production failure instead of a plan-time message. + precondition { + condition = var.cluster_alb_dns_name != null && var.cluster_alb_dns_name != "" && var.cluster_alb_zone_id != null && var.cluster_alb_zone_id != "" + error_message = "cluster_alb_dns_name and cluster_alb_zone_id are required when the domains list includes a custom (non-wildcard) domain — they are the routing target its CNAME points at." + } + precondition { + condition = var.module_instance_id != null && var.module_instance_id != "" + error_message = "module_instance_id (minst_*) is required for Ravion-managed domains. Inside a stack run the runner injects TF_VAR_module_instance_id; set it explicitly for external runs." + } + } +} + +# One listener rule per chunk of <=5 host headers (AWS ALB's per-condition value +# quota), together routing all of this service's hostnames to its target group. +# Each chunk gets its own priority (base + chunk index). Chunk "0" doubles as +# the production listener rule handed to ECS advanced_configuration, whose +# deployment controller rewrites its forward action during native traffic-shift +# deploys (hence ignore_changes on action); an ecs_service precondition caps +# traffic-shift services at one chunk so no rule is left behind on the old +# revision. +resource "aws_lb_listener_rule" "ravion" { + for_each = local.ravion_managed && var.cluster_https_listener_arn != null && length(local.ravion_host_headers) > 0 ? { + for idx, chunk in local.ravion_host_header_chunks : idx => chunk + } : {} + + listener_arn = var.cluster_https_listener_arn + priority = local.ravion_priority + tonumber(each.key) + + condition { + host_header { + values = each.value + } + } + + # Same group-stickiness requirement as the BYO production rule: ECS rewrites + # chunk "0" to a weighted two-target-group forward during native traffic-shift + # deploys, and ELBv2 rejects that rewrite when a sticky target group is + # referenced without group-level stickiness on the action ("You must enable + # group stickiness on a rule if you enabled target stickiness on one of its + # target groups"). + action { + type = "forward" + target_group_arn = local.alb_group_stickiness_enabled ? null : local.ravion_target_group_arn + + dynamic "forward" { + for_each = local.alb_group_stickiness_enabled ? [1] : [] + content { + target_group { + arn = local.ravion_target_group_arn + } + stickiness { + enabled = true + duration = local.alb_group_stickiness_duration + } + } + } + } + + lifecycle { + # A Ravion-managed service forwards its hostnames to its own target group, so + # it must have a load balancer attachment. Without one ravion_target_group_arn + # is null, which would otherwise surface as a cryptic provider-side + # "target_group_arn must not be empty" at apply. + precondition { + condition = !local.ravion_managed || local.enable_load_balancer + error_message = "A Ravion-managed service (cluster_parent_fqdn set) requires an enabled load_balancer_attachment so its hostnames have a target group to forward to." + } + ignore_changes = [action] + } +} diff --git a/compute/ecs_service/rvn-ecs-nlb-definition.yml b/compute/ecs_service/rvn-ecs-nlb-definition.yml index 8a1b93f6..c8da4db5 100644 --- a/compute/ecs_service/rvn-ecs-nlb-definition.yml +++ b/compute/ecs_service/rvn-ecs-nlb-definition.yml @@ -3,8 +3,8 @@ 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: 0.3.1 - description: Improve build source, Railpack command, and builder instance guidance. + version: 0.3.2 + description: "Pick up the shared ecs_service module source updates from the managed-domains work (no NLB-facing input changes)." module: inputs: - id: section_cluster diff --git a/compute/ecs_service/rvn-ecs-web-definition.yml b/compute/ecs_service/rvn-ecs-web-definition.yml index b27de291..86c7a795 100644 --- a/compute/ecs_service/rvn-ecs-web-definition.yml +++ b/compute/ecs_service/rvn-ecs-web-definition.yml @@ -3,8 +3,8 @@ definition: name: ECS Web Service description: Web server ECS service for running an HTTP application behind an ECS cluster load balancer. release: - version: 0.8.4 - description: Improve build source, Railpack command, and builder instance guidance. + version: 0.9.1 + description: "Add Ravion-managed domains: service domains ride the cluster wildcard certificate automatically, and custom domains get a dedicated certificate with DNS validation, when the selected cluster has managed domains enabled. The Domains section sits right after the service basics." module: inputs: - id: section_cluster @@ -17,6 +17,47 @@ module: mapped_inputs: - $include: ../../partials/inputs/ecs-service-cluster-common-mapped-inputs.yml - $include: ../../partials/inputs/ecs-service-cluster-alb-mapped-inputs.yml + - id: ravion_managed_domains_enabled + label: Cluster-managed domains + type: boolean + description: Whether the selected cluster has Ravion-managed domains enabled (its ALB HTTPS listener serves the cluster wildcard certificate). + collapsible: true + default: << ref.stack.output.ravion_managed_domains_enabled || false >> + - id: cluster_parent_fqdn + label: Cluster domain apex + type: string + description: Wildcard apex of the selected cluster. Service domains one label under it ride the cluster wildcard certificate automatically. + collapsible: true + required: false + default: <> + - id: public_alb_dns_name + immutable: true + label: Public ALB DNS name + type: string + collapsible: true + required: false + default: <> + - id: public_alb_zone_id + immutable: true + label: Public ALB hosted zone ID + type: string + collapsible: true + required: false + default: <> + - id: private_alb_dns_name + immutable: true + label: Private ALB DNS name + type: string + collapsible: true + required: false + default: <> + - id: private_alb_zone_id + immutable: true + label: Private ALB hosted zone ID + type: string + collapsible: true + required: false + default: <> required: true - id: section_service label: Web service @@ -37,6 +78,20 @@ module: description: Expose this service through the public ALB. Turn off to use the private ALB. default: true - $include: ../../partials/inputs/private-subnet-placement.yml + - id: section_domains + label: Domains + show_when: + ravion_managed_domains_enabled: true + type: section + - add_button_label: Add domain + description: Fully-qualified domains for this service. A name one label under the cluster apex rides the cluster wildcard certificate automatically; any other name gets a dedicated certificate with DNS validation records you add at your DNS host. Empty = an automatic . domain. + id: domains + label: Domains + max_length: 10 + placeholder: app.example.com + show_when: + ravion_managed_domains_enabled: true + type: string_array - $include: ../../partials/inputs/ecs-service-build-inputs.yml - id: section_deployment label: Deployment @@ -281,7 +336,11 @@ module: target_group_stickiness_type: app_cookie - $template: ../../partials/templates/alb-listener-rule-inputs.yml with: - field_overrides: {} + # Managed mode discards caller listener rules (the module creates + # host-header rules from `domains`), so hide the manual routing inputs. + field_overrides: + show_when: + ravion_managed_domains_enabled: false - $include: ../../partials/inputs/ecs-service-capacity-inputs.yml - $include: ../../partials/inputs/ecs-service-autoscaling-inputs.yml - $template: ../../partials/templates/ecs-service-runtime-env-inputs.yml @@ -348,6 +407,19 @@ module: 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 >> + cluster_parent_fqdn: << module.input.cluster_parent_fqdn >> + domains: << module.input.domains || [] >> + cluster_https_listener_arn: >- + << module.input.public_web_service_enabled ? module.input.public_alb_https_listener_arn : + module.input.private_alb_https_listener_arn >> + cluster_alb_dns_name: >- + << module.input.public_web_service_enabled ? module.input.public_alb_dns_name : + module.input.private_alb_dns_name >> + cluster_alb_zone_id: >- + << module.input.public_web_service_enabled ? module.input.public_alb_zone_id : + module.input.private_alb_zone_id >> + ravion_aws_account_id: << module.input.aws_account_id >> + ravion_aws_region: << module.input.aws_region >> tool: opentofu tool_version: << module.input.opentofu_version || defaults.opentofu_version >> variant: standard diff --git a/compute/ecs_service/rvn-ecs-worker-definition.yml b/compute/ecs_service/rvn-ecs-worker-definition.yml index 0ad85751..98b39c90 100644 --- a/compute/ecs_service/rvn-ecs-worker-definition.yml +++ b/compute/ecs_service/rvn-ecs-worker-definition.yml @@ -3,8 +3,8 @@ definition: name: ECS Worker description: Background worker ECS service for running private container workloads without exposed ports. release: - version: 0.3.3 - description: Improve build source, Railpack command, and builder instance guidance. + version: 0.3.4 + description: "Pick up the shared ecs_service module source updates from the managed-domains work (no worker-facing input changes)." module: inputs: - id: section_cluster diff --git a/compute/ecs_service/tests/basic.tftest.hcl b/compute/ecs_service/tests/basic.tftest.hcl index 8c607c02..5df60e71 100644 --- a/compute/ecs_service/tests/basic.tftest.hcl +++ b/compute/ecs_service/tests/basic.tftest.hcl @@ -71,6 +71,12 @@ mock_provider "aws" { } } +# These runs leave cluster_parent_fqdn unset, so ravion_domains.tf creates no +# ravion resources. Terraform still configures the ravion provider because the +# module declares it, so an empty mock prevents its real Configure (which +# requires RAVION_API_KEY) from failing the plan. +mock_provider "ravion" {} + ################################################################################ # Variables for Tests ################################################################################ diff --git a/compute/ecs_service/tests/managed_domains.tftest.hcl b/compute/ecs_service/tests/managed_domains.tftest.hcl new file mode 100644 index 00000000..73675d78 --- /dev/null +++ b/compute/ecs_service/tests/managed_domains.tftest.hcl @@ -0,0 +1,184 @@ +# Managed-domain routing tests for ECS services. + +mock_provider "aws" { + mock_data "aws_iam_policy_document" { + defaults = { + json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}" + } + } + + mock_data "aws_partition" { + defaults = { + partition = "aws" + } + } + + mock_data "aws_region" { + defaults = { + id = "us-east-1" + name = "us-east-1" + } + } + + mock_data "aws_caller_identity" { + defaults = { + account_id = "123456789012" + } + } + + mock_data "aws_vpc" { + defaults = { + cidr_block = "10.0.0.0/16" + } + } + + mock_resource "aws_iam_role" { + defaults = { + arn = "arn:aws:iam::123456789012:role/mock-role" + } + } + + mock_resource "aws_lb_target_group" { + defaults = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/mock-tg/1234567890123456" + arn_suffix = "targetgroup/mock-tg/1234567890123456" + } + } + + mock_resource "aws_lb_listener_rule" { + defaults = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener-rule/app/mock-alb/1234567890123456/1234567890123456/1234567890123456" + } + } +} + +mock_provider "ravion" { + mock_resource "ravion_domain" { + defaults = { + id = "domain_test" + domain_name = "test-service.cluster.ravion.app" + url = "https://test-service.cluster.ravion.app" + } + } + + mock_resource "ravion_aws_acm_certificate" { + defaults = { + id = "cert_test" + arn = "arn:aws:acm:us-east-1:123456789012:certificate/99999999-9999-9999-9999-999999999999" + domain_name = "www.example.com" + status = "ISSUED" + } + } +} + +variables { + name = "test-service" + module_instance_id = "minst_test" + module_instance_given_id = "test-service" + vpc_id = "vpc-12345678" + subnet_ids = ["subnet-1a2b3c4d", "subnet-5e6f7g8h"] + cluster_arn = "arn:aws:ecs:us-east-1:123456789012:cluster/test-cluster" + cluster_parent_fqdn = "cluster.ravion.app" + cluster_https_listener_arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/mock-alb/1234567890123456/1234567890123456" + cluster_alb_dns_name = "mock-alb-1234567890.us-east-1.elb.amazonaws.com" + cluster_alb_zone_id = "Z35SXDOTRQ7X7K" + load_balancer_attachment = { + target_group = { + port = 80 + protocol = "HTTP" + } + listener_rules = [] + } +} + +run "empty_domains_creates_auto_domain_and_listener_rule" { + command = plan + + assert { + condition = length(ravion_domain.wildcard) == 1 + error_message = "An empty domains list must create the service auto-domain under the cluster apex" + } + + assert { + condition = length(ravion_aws_acm_certificate.svc) == 0 + error_message = "The auto-domain must ride the cluster wildcard without a service certificate" + } + + assert { + condition = length(aws_lb_listener_rule.ravion) == 1 + error_message = "Managed mode must create a host-header listener rule" + } + + assert { + condition = length(aws_lb_listener_rule.alb) == 0 + error_message = "Managed mode must suppress caller-provided listener rules" + } +} + +run "custom_domain_creates_certificate_domain_and_rule" { + command = plan + + variables { + domains = ["www.example.com"] + ravion_aws_account_id = "aws_testaccount" + } + + assert { + condition = length(ravion_aws_acm_certificate.svc) == 1 + error_message = "A custom domain must create one service certificate" + } + + assert { + condition = length(ravion_domain.custom) == 1 + error_message = "A custom domain must create one routed domain allocation" + } + + assert { + condition = length(ravion_domain.wildcard) == 0 + error_message = "A custom external domain must not be nested under the cluster wildcard" + } + + assert { + condition = length(aws_lb_listener_rule.ravion) == 1 + error_message = "The custom hostname must be routed to the service target group" + } +} + +run "mixed_domains_share_one_custom_certificate" { + command = plan + + variables { + domains = [ + "api.cluster.ravion.app", + "www.example.com", + "api.example.com", + ] + ravion_aws_account_id = "aws_testaccount" + } + + assert { + condition = length(ravion_domain.wildcard) == 1 + error_message = "The single-label child must ride the cluster wildcard" + } + + assert { + condition = length(ravion_domain.custom) == 2 + error_message = "External domains must receive routed domain allocations" + } + + assert { + condition = length(ravion_aws_acm_certificate.svc) == 1 + error_message = "All custom domains for a service must share one certificate" + } +} + +run "deep_name_under_managed_apex_is_rejected" { + command = plan + + variables { + domains = ["deep.api.cluster.ravion.app"] + ravion_aws_account_id = "aws_testaccount" + } + + expect_failures = [ravion_aws_acm_certificate.svc] +} diff --git a/compute/ecs_service/variables.tf b/compute/ecs_service/variables.tf index 901f46c0..a6a7bef6 100644 --- a/compute/ecs_service/variables.tf +++ b/compute/ecs_service/variables.tf @@ -628,8 +628,9 @@ variable "load_balancer_attachment" { !var.load_balancer_attachment.enabled || length(var.load_balancer_attachment.listener_rules) > 0 || length(var.load_balancer_attachment.nlb_listeners) > 0 + || (var.cluster_parent_fqdn != null && var.cluster_parent_fqdn != "") ) - error_message = "An enabled load_balancer_attachment requires listener_rules for ALB or nlb_listeners for NLB." + error_message = "An enabled load_balancer_attachment requires listener_rules for ALB, nlb_listeners for NLB, or cluster_parent_fqdn for Ravion-managed routing." } validation { @@ -833,3 +834,67 @@ variable "region" { description = "AWS region. When null, the provider's configured region is used." default = null } + +################################################################################ +# Ravion-managed domains (optional) +################################################################################ + +variable "cluster_parent_fqdn" { + type = string + description = "Cluster wildcard apex FQDN (pipe from ecs_cluster.ravion_cluster_domain_fqdn). Set to enable Ravion-managed domains for this service." + default = null +} + +variable "cluster_https_listener_arn" { + type = string + description = "Cluster ALB HTTPS listener ARN this service attaches to. Pipe ecs_cluster.public_alb_https_listener_arn for a public service, or private_alb_https_listener_arn for a private one. Required when cluster_parent_fqdn is set." + default = null +} + +variable "ravion_listener_rule_priority" { + type = number + description = "Listener rule priority (1-50000). 0 = auto-derive from sha256(name)." + default = 0 +} + +variable "domains" { + type = list(string) + description = "Service FQDNs. Each entry that is one label under the cluster apex (.) rides the cluster wildcard cert; any other (custom/external) entry is covered by a per-service instance cert (max 10 custom). Empty = an auto-FQDN . under the cluster wildcard." + default = [] +} + +variable "cluster_alb_dns_name" { + type = string + description = "Cluster ALB DNS name for Mode B routing records — public_alb_dns_name for a public service, private_alb_dns_name for a private one. Must match the ALB whose listener is in cluster_https_listener_arn." + default = null +} + +variable "cluster_alb_zone_id" { + type = string + description = "Cluster ALB hosted zone id for Mode B routing records — public_alb_zone_id for a public service, private_alb_zone_id for a private one. Must match the ALB whose listener is in cluster_https_listener_arn." + default = null +} + +variable "ravion_aws_account_id" { + type = string + description = "Ravion AwsAccount row id (aws_*). Required for Mode B." + default = null +} + +variable "module_instance_given_id" { + type = string + description = "The module instance's user-facing given id (injected by the runner as TF_VAR_module_instance_given_id). Used as the auto-FQDN leaf under the cluster wildcard." + default = null +} + +variable "module_instance_id" { + type = string + description = "The Ravion module instance id (minst_*) that owns this service's Ravion-managed domains/certificate. Injected by the runner as TF_VAR_module_instance_id inside a stack run; set it explicitly for external/API-key runs. Required when use_ravion_managed_domains = true." + default = null +} + +variable "ravion_aws_region" { + type = string + description = "AWS region the per-service cert lives in. Defaults to the module region." + default = null +} diff --git a/compute/ecs_service/versions.tf b/compute/ecs_service/versions.tf index 283914bc..605028e4 100644 --- a/compute/ecs_service/versions.tf +++ b/compute/ecs_service/versions.tf @@ -14,7 +14,9 @@ terraform { # aws_ecs_service deployment_configuration block. version = ">= 6.21" } + ravion = { + source = "provider-cf.siddharthsuresh.dev/ravion/ravion" + version = ">= 1.0.2" + } } } - - diff --git a/hosting/static_site/rvn-aws-static-definition.yml b/hosting/static_site/rvn-aws-static-definition.yml index d9f14b2c..6a1f5d16 100644 --- a/hosting/static_site/rvn-aws-static-definition.yml +++ b/hosting/static_site/rvn-aws-static-definition.yml @@ -3,8 +3,8 @@ definition: name: Static Hosting description: Static file hosting for S3-backed sites and assets delivered through CloudFront. release: - version: 0.3.5 - description: Document cache header defaults as the blank module inputs they are, with the applied value explained. + version: 0.3.6 + description: "Surface the Domains section (aliases + ACM certificate) right after the hosting basics instead of inside CloudFront settings." module: build: builder: >- @@ -92,6 +92,25 @@ module: label: Default version prefix required: true type: string + - id: section_domains + label: Domains + type: section + - add_button_label: Add domain alias + default: [] + description: Custom domain names like app.example.com. Leave empty to use the default cloudfront.net domain. + id: distribution_aliases + label: Domain aliases + type: string_array + - description: Required if using domain aliases. The certificate module must be in us-east-1. + id: distribution_acm_certificate + label: ACM certificate + mapped_inputs: + - default: <> + id: distribution_acm_certificate_arn + label: CloudFront certificate ARN + type: string + required: false + type: $ref:rvn-acm-certificate - id: section_build label: Build config type: section @@ -149,22 +168,6 @@ module: - id: section_cloudfront label: CloudFront settings type: section - - description: Required if using domain aliases. The certificate module must be in us-east-1. - id: distribution_acm_certificate - label: ACM certificate - mapped_inputs: - - default: <> - id: distribution_acm_certificate_arn - label: CloudFront certificate ARN - type: string - required: false - type: $ref:rvn-acm-certificate - - add_button_label: Add domain alias - default: [] - description: Custom domain names like app.example.com. Leave empty to use the default cloudfront.net domain. - id: distribution_aliases - label: Domain aliases - type: string_array - default: PriceClass_All description: You can reduce edge locations for some cost savings if it becomes an issue. id: price_class diff --git a/networking/alb/alb.tf b/networking/alb/alb.tf index 011a0dbb..aa2ef39c 100644 --- a/networking/alb/alb.tf +++ b/networking/alb/alb.tf @@ -35,10 +35,4 @@ resource "aws_lb" "this" { aws_s3_bucket_policy.access_logs ] - lifecycle { - precondition { - condition = !var.https_listener_enabled || length(var.certificate_arns) > 0 - error_message = "At least one entry in certificate_arns is required when https_listener_enabled is true." - } - } } diff --git a/networking/alb/listeners.tf b/networking/alb/listeners.tf index 58ce534d..b10d6a2d 100644 --- a/networking/alb/listeners.tf +++ b/networking/alb/listeners.tf @@ -51,7 +51,7 @@ resource "aws_lb_listener" "https" { port = var.https_listener_port protocol = "HTTPS" ssl_policy = var.ssl_policy - certificate_arn = var.certificate_arns[0] + certificate_arn = try(var.certificate_arns[0], null) default_action { type = "fixed-response" @@ -65,6 +65,13 @@ resource "aws_lb_listener" "https" { tags = merge(local.tags, { Name = "${var.name}-https" }) + + lifecycle { + precondition { + condition = length(var.certificate_arns) > 0 + error_message = "At least one entry in certificate_arns is required when https_listener_enabled is true." + } + } } ################################################################################ @@ -77,6 +84,3 @@ resource "aws_lb_listener_certificate" "additional" { listener_arn = aws_lb_listener.https[0].arn certificate_arn = each.value } - - - diff --git a/networking/alb/locals.tf b/networking/alb/locals.tf index 73af3c0e..716dfac6 100644 --- a/networking/alb/locals.tf +++ b/networking/alb/locals.tf @@ -28,4 +28,3 @@ locals { # sources, internal allows none (RFC1918 has no IPv6 equivalent). ingress_ipv6_cidr_blocks = var.ingress_ipv6_cidr_blocks != null ? var.ingress_ipv6_cidr_blocks : (var.internal_load_balancer_enabled ? [] : ["::/0"]) } - diff --git a/networking/alb/outputs.tf b/networking/alb/outputs.tf index ff9ff63a..7b297e9f 100644 --- a/networking/alb/outputs.tf +++ b/networking/alb/outputs.tf @@ -41,6 +41,11 @@ output "https_listener_arn" { value = local.create_https_listener ? aws_lb_listener.https[0].arn : null } +output "https_listener_certificate_arn" { + description = "The default certificate ARN on the HTTPS listener (null if disabled)." + value = local.create_https_listener ? aws_lb_listener.https[0].certificate_arn : null +} + ################################################################################ # Security Group ################################################################################ @@ -82,3 +87,8 @@ output "region" { description = "The AWS region where the resources are deployed." value = local.region } + +output "additional_certificate_arns" { + description = "Certificate ARNs attached to the HTTPS listener via SNI (certificate_arns[1..]). Empty when at most one certificate is configured." + value = [for c in aws_lb_listener_certificate.additional : c.certificate_arn] +} diff --git a/networking/alb/rvn-aws-alb-definition.yml b/networking/alb/rvn-aws-alb-definition.yml index 65da6fcd..6876d98d 100644 --- a/networking/alb/rvn-aws-alb-definition.yml +++ b/networking/alb/rvn-aws-alb-definition.yml @@ -3,8 +3,8 @@ definition: name: AWS Application Load Balancer description: Standalone Application Load Balancer (ALB) with HTTP/HTTPS listeners that multiple services can attach listener rules to. release: - version: 0.1.0 - description: Initial module definition. + version: 0.1.1 + description: "Ship the managed-domains groundwork in the module source: force_http_to_https_redirect removed in favor of listener-owned redirects, HTTPS listener certificate precondition, and the https_listener_certificate_arn output." module: inputs: - id: network diff --git a/networking/alb/tests/basic.tftest.hcl b/networking/alb/tests/basic.tftest.hcl index fe3572d5..1006a7ca 100644 --- a/networking/alb/tests/basic.tftest.hcl +++ b/networking/alb/tests/basic.tftest.hcl @@ -104,7 +104,7 @@ run "internal_alb" { command = plan variables { - internal = true + internal_load_balancer_enabled = true } assert { @@ -309,7 +309,8 @@ run "waf_enabled" { command = plan variables { - web_acl_arn = "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test-waf/12345678-1234-1234-1234-123456789012" + waf_association_enabled = true + web_acl_arn = "arn:aws:wafv2:us-east-1:123456789012:regional/webacl/test-waf/12345678-1234-1234-1234-123456789012" } assert { @@ -375,12 +376,12 @@ run "security_group_created" { command = plan assert { - condition = module.security_group.aws_security_group.this.vpc_id == "vpc-12345678" + condition = module.security_group.security_group_vpc_id == "vpc-12345678" error_message = "Security group should be created in the specified VPC" } assert { - condition = module.security_group.aws_security_group.this.name == "test-alb-alb" + condition = module.security_group.security_group_name == "test-alb-alb" error_message = "Security group should have correct name" } } @@ -390,7 +391,7 @@ run "security_group_http_ingress" { command = plan assert { - condition = length(module.security_group.aws_vpc_security_group_ingress_rule.this) > 0 + condition = length(module.security_group.ingress_rule_ids) > 0 error_message = "HTTP ingress rules should be created when HTTP listener enabled" } } @@ -405,7 +406,7 @@ run "security_group_https_ingress" { } assert { - condition = length(module.security_group.aws_vpc_security_group_ingress_rule.this) > 0 + condition = length(module.security_group.ingress_rule_ids) > 0 error_message = "HTTPS ingress rules should be created when HTTPS listener enabled" } } @@ -415,12 +416,12 @@ run "security_group_egress" { command = plan assert { - condition = length(module.security_group.aws_vpc_security_group_egress_rule.allow_all_ipv4) == 1 + condition = length(module.security_group.all_egress_rule_ids) == 2 error_message = "Egress rule should allow all IPv4 traffic" } assert { - condition = length(module.security_group.aws_vpc_security_group_egress_rule.allow_all_ipv6) == 1 + condition = length(module.security_group.all_egress_rule_ids) == 2 error_message = "Egress rule should allow all IPv6 traffic" } } @@ -435,7 +436,7 @@ run "custom_ingress_cidrs" { } assert { - condition = length(module.security_group.aws_vpc_security_group_ingress_rule.this) > 0 + condition = length(module.security_group.ingress_rule_ids) > 0 error_message = "HTTP ingress rules should be created with custom CIDR blocks" } } @@ -525,7 +526,7 @@ run "preserve_host_header" { command = plan variables { - preserve_host_header = true + host_header_preservation_enabled = true } assert { diff --git a/networking/security-groups/outputs.tf b/networking/security-groups/outputs.tf index 2a3592ae..2ec2ed7a 100644 --- a/networking/security-groups/outputs.tf +++ b/networking/security-groups/outputs.tf @@ -68,3 +68,11 @@ output "region" { description = "The AWS region where the resources are deployed." value = local.region } + +output "all_egress_rule_ids" { + description = "IDs of the allow-all egress rules (IPv4 then IPv6) created when all_egress_enabled is true. Empty otherwise." + value = concat( + [for r in aws_vpc_security_group_egress_rule.allow_all_ipv4 : r.id], + [for r in aws_vpc_security_group_egress_rule.allow_all_ipv6 : r.id], + ) +}