diff --git a/compute/ecs_cluster/listeners.tf b/compute/ecs_cluster/listeners.tf new file mode 100644 index 0000000..4c0785f --- /dev/null +++ b/compute/ecs_cluster/listeners.tf @@ -0,0 +1,204 @@ +################################################################################ +# Cluster ALB HTTPS listeners +################################################################################ +# ecs_cluster ALWAYS owns the cluster ALB HTTPS listener(s) (the alb submodule +# never creates them — see load_balancers.tf) so that toggling +# var.use_ravion_managed_domains is an IN-PLACE certificate swap on a stable TF +# address rather than a destroy+create across two addresses. Only the default +# certificate SOURCE changes by mode: +# +# - use_ravion_managed_domains = true -> the Ravion wildcard cert +# (ravion_aws_acm_certificate.cluster, see ravion_domains.tf) is the default cert on +# BOTH listeners; public/private services nest their auto-FQDNs under it. +# - use_ravion_managed_domains = false -> the listener uses the customer's +# first public/private_alb_certificate_arns entry as default and attaches +# the rest for SNI. +# +# The listeners live here (not in the alb submodule) to avoid a DAG cycle: +# aws_lb.this -> ravion_aws_acm_certificate.cluster -> aws_lb_listener.public_https +# (uses the cert). ravion_aws_acm_certificate with role=shared_wildcard blocks until +# ISSUED, so cert_arn is valid at listener create time. + +# Public ALB HTTPS listener. Mode-independent address: created whenever the +# public ALB has HTTPS enabled. +resource "aws_lb_listener" "public_https" { + count = var.enable_public_alb && var.public_alb_enable_https ? 1 : 0 + + load_balancer_arn = module.public_alb[0].alb_arn + port = 443 + protocol = "HTTPS" + ssl_policy = var.public_alb_ssl_policy + # try(...) defers to the precondition below for the clean error when BYO mode + # has no cert ARN, instead of a cryptic index-out-of-range. + certificate_arn = local.enable_ravion_domain ? ravion_aws_acm_certificate.cluster[0].arn : try(var.public_alb_certificate_arns[0], null) + + default_action { + type = "fixed-response" + fixed_response { + content_type = "text/plain" + message_body = "Not found" + status_code = "404" + } + } + + lifecycle { + precondition { + condition = local.enable_ravion_domain || length(var.public_alb_certificate_arns) >= 1 + error_message = "public_alb_certificate_arns must include at least one ACM certificate ARN when public_alb_enable_https = true and use_ravion_managed_domains = false." + } + } + + tags = merge(local.tags, { Name = "${var.name}-pub-https" }) +} + +# Customer SNI certs for the public listener (BYO mode only; the Ravion wildcard +# needs no extra SNI certs). Gated on the listener existing so the slice is +# never evaluated when HTTPS / the ALB is off (mirrors the alb submodule's +# `additional` idiom and avoids slice([], 1, 0) on the default config). +resource "aws_lb_listener_certificate" "public_sni" { + # length > 1 keeps slice() self-safe (never slice([], 1, 0)) independent of the + # listener precondition: only the 2nd+ ARNs become SNI certs. + for_each = (var.enable_public_alb && var.public_alb_enable_https && !local.enable_ravion_domain && length(var.public_alb_certificate_arns) > 1) ? toset(slice(var.public_alb_certificate_arns, 1, length(var.public_alb_certificate_arns))) : toset([]) + + listener_arn = aws_lb_listener.public_https[0].arn + certificate_arn = each.value +} + +# Private ALB HTTPS listener (same Ravion wildcard cert as the public one in +# managed mode; the customer's first private cert ARN otherwise). +resource "aws_lb_listener" "private_https" { + count = var.enable_private_alb && var.private_alb_enable_https ? 1 : 0 + + load_balancer_arn = module.private_alb[0].alb_arn + port = 443 + protocol = "HTTPS" + ssl_policy = var.private_alb_ssl_policy + certificate_arn = local.enable_ravion_domain ? ravion_aws_acm_certificate.cluster[0].arn : try(var.private_alb_certificate_arns[0], null) + + default_action { + type = "fixed-response" + fixed_response { + content_type = "text/plain" + message_body = "Not found" + status_code = "404" + } + } + + lifecycle { + precondition { + condition = local.enable_ravion_domain || length(var.private_alb_certificate_arns) >= 1 + error_message = "private_alb_certificate_arns must include at least one ACM certificate ARN when private_alb_enable_https = true and use_ravion_managed_domains = false." + } + } + + tags = merge(local.tags, { Name = "${var.name}-priv-https" }) +} + +# Customer SNI certs for the private listener (BYO mode only). +resource "aws_lb_listener_certificate" "private_sni" { + for_each = (var.enable_private_alb && var.private_alb_enable_https && !local.enable_ravion_domain && length(var.private_alb_certificate_arns) > 1) ? toset(slice(var.private_alb_certificate_arns, 1, length(var.private_alb_certificate_arns))) : toset([]) + + listener_arn = aws_lb_listener.private_https[0].arn + certificate_arn = each.value +} + +################################################################################ +# 443 ingress +################################################################################ +# The alb submodule only opens 443 when it owns the HTTPS listener; it no longer +# does, so ecs_cluster opens 443 here in BOTH modes (mirrors the submodule's +# rules). Mode-independent so toggling use_ravion_managed_domains never churns +# the SG rules. +resource "aws_vpc_security_group_ingress_rule" "public_https_ipv4" { + for_each = var.enable_public_alb && var.public_alb_enable_https ? toset(var.public_alb_ingress_cidr_blocks) : toset([]) + + security_group_id = module.public_alb[0].security_group_id + description = "Allow HTTPS from ${each.value}" + cidr_ipv4 = each.value + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + tags = local.tags +} + +resource "aws_vpc_security_group_ingress_rule" "public_https_ipv6" { + for_each = var.enable_public_alb && var.public_alb_enable_https ? toset(["::/0"]) : toset([]) + + security_group_id = module.public_alb[0].security_group_id + description = "Allow HTTPS from ${each.value}" + cidr_ipv6 = each.value + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + tags = local.tags +} + +# Private ALB 443 ingress (mirrors the public rules for the private listener). +resource "aws_vpc_security_group_ingress_rule" "private_https_ipv4" { + for_each = var.enable_private_alb && var.private_alb_enable_https ? toset(var.private_alb_ingress_cidr_blocks) : toset([]) + + security_group_id = module.private_alb[0].security_group_id + description = "Allow HTTPS from ${each.value}" + cidr_ipv4 = each.value + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + tags = local.tags +} + +resource "aws_vpc_security_group_ingress_rule" "private_https_ipv6" { + for_each = var.enable_private_alb && var.private_alb_enable_https ? toset(["::/0"]) : toset([]) + + security_group_id = module.private_alb[0].security_group_id + description = "Allow HTTPS from ${each.value}" + cidr_ipv6 = each.value + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + tags = local.tags +} + +################################################################################ +# State moves +################################################################################ +# Renames within ecs_cluster (clusters already in Ravion mode keep their state): +moved { + from = aws_lb_listener.ravion_https + to = aws_lb_listener.public_https +} + +moved { + from = aws_lb_listener.ravion_https_private + to = aws_lb_listener.private_https +} + +moved { + from = aws_vpc_security_group_ingress_rule.ravion_https_ipv4 + to = aws_vpc_security_group_ingress_rule.public_https_ipv4 +} + +moved { + from = aws_vpc_security_group_ingress_rule.ravion_https_ipv6 + to = aws_vpc_security_group_ingress_rule.public_https_ipv6 +} + +moved { + from = aws_vpc_security_group_ingress_rule.ravion_https_private_ipv4 + to = aws_vpc_security_group_ingress_rule.private_https_ipv4 +} + +# BYO clusters with existing state had their HTTPS listener inside the alb +# submodule; refactoring it out to the root is expressed with a cross-module +# moved block (supported "refactor out of a module" pattern). The submodule's +# 443 SG ingress rules came from a for_each in the security-groups module and +# cannot be moved this way — those are a one-time destroy+create on the BYO +# migration (acceptable: nothing is in prod yet). +moved { + from = module.public_alb[0].aws_lb_listener.https[0] + to = aws_lb_listener.public_https[0] +} + +moved { + from = module.private_alb[0].aws_lb_listener.https[0] + to = aws_lb_listener.private_https[0] +} diff --git a/compute/ecs_cluster/load_balancers.tf b/compute/ecs_cluster/load_balancers.tf index 9eaec97..17145a6 100644 --- a/compute/ecs_cluster/load_balancers.tf +++ b/compute/ecs_cluster/load_balancers.tf @@ -14,13 +14,19 @@ module "public_alb" { subnet_ids = var.public_subnet_ids internal = false - # Listener configuration - enable_http_listener = true - enable_https_listener = var.public_alb_enable_https - http_to_https_redirect = var.public_alb_enable_https + # Listener configuration. ecs_cluster ALWAYS owns the HTTPS listener (in + # ravion_domains.tf) so toggling use_ravion_managed_domains is an in-place + # cert swap rather than a destroy+create across TF addresses. The alb + # submodule therefore never creates its own HTTPS listener nor holds cert + # ARNs; force_http_to_https_redirect keeps the HTTP listener redirecting to + # the parent-owned 443 listener. + enable_http_listener = true + enable_https_listener = false + http_to_https_redirect = var.public_alb_enable_https + force_http_to_https_redirect = var.public_alb_enable_https # SSL/TLS - certificate_arns = var.public_alb_certificate_arns + certificate_arns = [] ssl_policy = var.public_alb_ssl_policy # ALB settings @@ -54,13 +60,19 @@ module "private_alb" { subnet_ids = var.private_subnet_ids internal = true - # Listener configuration - enable_http_listener = true - enable_https_listener = var.private_alb_enable_https - http_to_https_redirect = var.private_alb_enable_https + # Listener configuration. ecs_cluster ALWAYS owns the HTTPS listener (in + # ravion_domains.tf) so toggling use_ravion_managed_domains is an in-place + # cert swap rather than a destroy+create across TF addresses. The alb + # submodule therefore never creates its own HTTPS listener nor holds cert + # ARNs; force_http_to_https_redirect keeps the HTTP listener redirecting to + # the parent-owned 443 listener. + enable_http_listener = true + enable_https_listener = false + http_to_https_redirect = var.private_alb_enable_https + force_http_to_https_redirect = var.private_alb_enable_https # SSL/TLS - certificate_arns = var.private_alb_certificate_arns + 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 8d5a5cd..4f59dcc 100644 --- a/compute/ecs_cluster/outputs.tf +++ b/compute/ecs_cluster/outputs.tf @@ -120,8 +120,8 @@ output "public_alb_http_listener_arn" { } output "public_alb_https_listener_arn" { - description = "The ARN of the public ALB HTTPS listener (null if HTTPS disabled)." - value = var.enable_public_alb && var.public_alb_enable_https ? module.public_alb[0].https_listener_arn : null + description = "The ARN of the public ALB HTTPS listener (ecs_cluster-owned; null if HTTPS disabled)." + value = (var.enable_public_alb && var.public_alb_enable_https) ? aws_lb_listener.public_https[0].arn : null } ################################################################################ @@ -164,8 +164,8 @@ output "private_alb_http_listener_arn" { } output "private_alb_https_listener_arn" { - description = "The ARN of the private ALB HTTPS listener (null if HTTPS disabled)." - value = var.enable_private_alb && var.private_alb_enable_https ? module.private_alb[0].https_listener_arn : null + description = "The ARN of the private ALB HTTPS listener (ecs_cluster-owned; null if HTTPS disabled)." + value = (var.enable_private_alb && var.private_alb_enable_https) ? aws_lb_listener.private_https[0].arn : null } ################################################################################ @@ -240,3 +240,42 @@ 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 cert + HTTPS listener (use_ravion_managed_domains AND at least one ALB). Services read this to show/hide managed-domain fields." + value = local.enable_ravion_domain +} + +output "ravion_cluster_dependent_domains" { + description = "Live service domains nested under the cluster wildcard apex (they ride its cert/ALIAS). Tearing the cluster down while these exist is refused by the control plane (Dns:CERT_APEX_IN_USE)." + value = local.enable_ravion_domain ? one(data.ravion_apex_dependents.cluster[*].dependents) : [] +} diff --git a/compute/ecs_cluster/ravion_domains.tf b/compute/ecs_cluster/ravion_domains.tf new file mode 100644 index 0000000..581203b --- /dev/null +++ b/compute/ecs_cluster/ravion_domains.tf @@ -0,0 +1,78 @@ +################################################################################ +# 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 cluster ALB HTTPS listener(s) (see listeners.tf) — a single ACM cert +# can default both the public and the private listener, so public AND private +# services nest their domains under the one wildcard. The cert also publishes a +# `*.` ALIAS to the cluster ALB so service auto-FQDNs (.) +# resolve. When the flag is off, this resource is absent and the listeners fall +# back to the customer-supplied certificate ARNs. + +locals { + enable_ravion_domain = var.use_ravion_managed_domains && (var.enable_public_alb || var.enable_private_alb) +} + +# Plan-time guard against two clusters claiming the same wildcard apex. The +# backend resolves the bare name leaf to the managed wildcard (*..) +# and reports collides=true ONLY when a DIFFERENT module instance already owns +# that domain — a re-apply of THIS cluster does not collide with itself. The +# allocator enforces the same rule server-side as an apply-time backstop. +data "ravion_dns_collision_check" "cluster" { + count = local.enable_ravion_domain ? 1 : 0 + domain_name = coalesce(var.ravion_cluster_name, var.module_instance_given_id, var.name) +} + +resource "ravion_aws_acm_certificate" "cluster" { + count = local.enable_ravion_domain ? 1 : 0 + + role = "shared_wildcard" + 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. Public ALB if present, + # else private. (A single wildcard record serves one ALB; mixed public+private + # clusters route to the public one.) + target_dns_name = var.enable_public_alb ? module.public_alb[0].alb_dns_name : (var.enable_private_alb ? module.private_alb[0].alb_dns_name : null) + target_zone_id = var.enable_public_alb ? module.public_alb[0].alb_zone_id : (var.enable_private_alb ? 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 + + precondition { + condition = !var.use_ravion_managed_domains || var.enable_public_alb || var.enable_private_alb + error_message = "use_ravion_managed_domains requires at least one ALB (enable_public_alb or enable_private_alb)." + } + 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 = !coalesce(one(data.ravion_dns_collision_check.cluster[*].collides), false) + error_message = "Cluster wildcard apex is already claimed by another cluster: a managed *.. domain owned by a different module instance already exists. Pick a unique ravion_cluster_name." + } + } +} + +# Live service domains nested under this cluster's wildcard apex (they ride its +# cert + `*.` ALIAS). Surfaced via the ravion_cluster_dependent_domains +# output for the UI / safe-teardown orchestration. NOT used as a precondition: +# a cluster legitimately has dependents during normal operation and Terraform +# can't scope a precondition to destroy-time, so it would block every apply. +# The control plane already refuses a teardown while dependents exist +# (Dns:CERT_APEX_IN_USE), which is the real backstop. +data "ravion_apex_dependents" "cluster" { + count = local.enable_ravion_domain ? 1 : 0 + apex = ravion_aws_acm_certificate.cluster[0].domain_name +} diff --git a/compute/ecs_cluster/tests/basic.tftest.hcl b/compute/ecs_cluster/tests/basic.tftest.hcl index 2deb274..16a1506 100644 --- a/compute/ecs_cluster/tests/basic.tftest.hcl +++ b/compute/ecs_cluster/tests/basic.tftest.hcl @@ -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. listeners.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" diff --git a/compute/ecs_cluster/tests/listeners.tftest.hcl b/compute/ecs_cluster/tests/listeners.tftest.hcl new file mode 100644 index 0000000..2b727a7 --- /dev/null +++ b/compute/ecs_cluster/tests/listeners.tftest.hcl @@ -0,0 +1,263 @@ +# Cluster ALB HTTPS listener tests (sprint #1: unify the HTTPS listener so +# toggling use_ravion_managed_domains is an in-place cert swap, not a +# destroy+create). Self-contained: mocks both providers, EC2 disabled +# throughout so these runs are independent of the EC2 capacity-provider tests. +# Run with: tofu test + +# Valid ARNs are required: aws_lb_listener validates load_balancer_arn / +# certificate_arn at plan, and the auto-fabricated mock values aren't ARNs. +mock_provider "aws" { + 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_security_group.this + values = { + arn = "arn:aws:ec2:us-east-1:123456789012:security-group/sg-publicalb123456" + id = "sg-publicalb123456" + } + } + + override_resource { + target = aws_lb_listener.public_https + values = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-public-alb/1234567890123456/6543210987654321" + } + } + + 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_security_group.this + values = { + arn = "arn:aws:ec2:us-east-1:123456789012:security-group/sg-privatealb123456" + id = "sg-privatealb123456" + } + } + + override_resource { + target = aws_lb_listener.private_https + values = { + arn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/test-private-alb/1234567890123457/6543210987654322" + } + } +} + +# ravion_aws_acm_certificate needs a DomainProvider JWT to configure against the real +# control plane; mock it so tests are hermetic. The cert_arn override is a valid +# ACM ARN so the listener's certificate_arn passes provider validation. +mock_provider "ravion" { + override_resource { + target = ravion_aws_acm_certificate.cluster + values = { + id = "cert_test" + cert_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" + vpc_id = "vpc-12345678" + private_subnet_ids = ["subnet-private1", "subnet-private2"] + public_subnet_ids = ["subnet-public1", "subnet-public2"] +} + +################################################################################ +# BYO certificate mode (use_ravion_managed_domains = false, the default) +################################################################################ + +# A single cert ARN: ecs_cluster owns the HTTPS listener at a stable root +# address, the alb submodule owns none, and there are no SNI certs. +run "byo_public_https_single_cert" { + command = plan + + variables { + enable_public_alb = true + public_alb_enable_https = true + public_alb_certificate_arns = ["arn:aws:acm:us-east-1:111122223333:certificate/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"] + } + + assert { + condition = length(aws_lb_listener.public_https) == 1 + error_message = "ecs_cluster must own the public HTTPS listener" + } + + assert { + condition = module.public_alb[0].https_listener_arn == null + error_message = "The alb submodule must NOT own the HTTPS listener (its https_listener_arn output is null)" + } + + assert { + condition = aws_lb_listener.public_https[0].certificate_arn == "arn:aws:acm:us-east-1:111122223333:certificate/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + error_message = "BYO mode must use the customer's first cert ARN as the default cert" + } + + assert { + condition = length(aws_lb_listener_certificate.public_sni) == 0 + error_message = "A single cert ARN yields no SNI certificates" + } +} + +# Multiple cert ARNs: the 2nd+ are attached for SNI. +run "byo_public_https_sni" { + command = plan + + variables { + enable_public_alb = true + public_alb_enable_https = true + public_alb_certificate_arns = [ + "arn:aws:acm:us-east-1:111122223333:certificate/11111111-1111-1111-1111-111111111111", + "arn:aws:acm:us-east-1:111122223333:certificate/22222222-2222-2222-2222-222222222222", + "arn:aws:acm:us-east-1:111122223333:certificate/33333333-3333-3333-3333-333333333333", + ] + } + + assert { + condition = length(aws_lb_listener_certificate.public_sni) == 2 + error_message = "The 2nd+ cert ARNs must be attached as SNI certificates" + } +} + +# HTTPS enabled in BYO mode with no cert ARN must fail the precondition (the +# clean error, not a cryptic index-out-of-range). +run "byo_public_https_requires_cert" { + command = plan + + variables { + enable_public_alb = true + public_alb_enable_https = true + # no public_alb_certificate_arns; use_ravion_managed_domains defaults false + } + + expect_failures = [aws_lb_listener.public_https] +} + +# Private ALB BYO parity. +run "byo_private_https_single_cert" { + command = plan + + variables { + enable_private_alb = true + private_alb_enable_https = true + private_alb_certificate_arns = ["arn:aws:acm:us-east-1:111122223333:certificate/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"] + } + + assert { + condition = length(aws_lb_listener.private_https) == 1 + error_message = "ecs_cluster must own the private HTTPS listener" + } + + assert { + condition = module.private_alb[0].https_listener_arn == null + error_message = "The alb submodule must NOT own the private HTTPS listener (its https_listener_arn output is null)" + } +} + +# Private multi-cert: the 2nd+ are attached for SNI. +run "byo_private_https_sni" { + command = plan + + variables { + enable_private_alb = true + private_alb_enable_https = true + private_alb_certificate_arns = [ + "arn:aws:acm:us-east-1:111122223333:certificate/44444444-4444-4444-4444-444444444444", + "arn:aws:acm:us-east-1:111122223333:certificate/55555555-5555-5555-5555-555555555555", + ] + } + + assert { + condition = length(aws_lb_listener_certificate.private_sni) == 1 + error_message = "The 2nd+ private cert ARNs must be attached as SNI certificates" + } +} + +# Private HTTPS in BYO mode with no cert ARN must fail the precondition. +run "byo_private_https_requires_cert" { + command = plan + + variables { + enable_private_alb = true + private_alb_enable_https = true + # no private_alb_certificate_arns; use_ravion_managed_domains defaults false + } + + expect_failures = [aws_lb_listener.private_https] +} + +################################################################################ +# Ravion-managed mode (use_ravion_managed_domains = true) +################################################################################ + +# The wildcard cert is issued and becomes the listener default; no SNI certs, +# no customer cert ARN required. Same listener address as BYO mode (the +# toggle is an in-place cert swap, not a destroy+create). +run "ravion_managed_public_https" { + command = plan + + variables { + enable_public_alb = true + public_alb_enable_https = true + use_ravion_managed_domains = true + ravion_aws_account_id = "aws_testaccount" + } + + assert { + condition = length(ravion_aws_acm_certificate.cluster) == 1 + error_message = "Ravion wildcard cert must be created in managed mode" + } + + assert { + condition = length(aws_lb_listener.public_https) == 1 + error_message = "The HTTPS listener exists at the same address in managed mode" + } + + assert { + condition = length(aws_lb_listener_certificate.public_sni) == 0 + error_message = "Managed mode attaches no customer SNI certs" + } +} + +################################################################################ +# HTTP-only: no HTTPS listener, and the SNI slice is never evaluated +################################################################################ + +# Regression guard for the slice([], 1, 0) crash: with HTTPS off and no certs, +# the SNI for_each must short-circuit to an empty set rather than evaluating +# the slice. +run "public_alb_http_only_no_sni_eval" { + command = plan + + variables { + enable_public_alb = true + public_alb_enable_https = false + } + + assert { + condition = length(aws_lb_listener.public_https) == 0 + error_message = "No HTTPS listener when public_alb_enable_https = false" + } + + assert { + condition = length(aws_lb_listener_certificate.public_sni) == 0 + error_message = "SNI set must be empty (slice not evaluated) when HTTPS is off" + } +} diff --git a/compute/ecs_cluster/variables.tf b/compute/ecs_cluster/variables.tf index c7e37d7..85571a5 100644 --- a/compute/ecs_cluster/variables.tf +++ b/compute/ecs_cluster/variables.tf @@ -629,3 +629,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 have Ravion own the public ALB HTTPS listener cert. Requires enable_public_alb = true." + 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 bec739b..cbae5c4 100644 --- a/compute/ecs_cluster/versions.tf +++ b/compute/ecs_cluster/versions.tf @@ -12,6 +12,12 @@ 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.0" + } } } diff --git a/compute/ecs_service/listener_rules.tf b/compute/ecs_service/listener_rules.tf index 7d238a1..6f24198 100644 --- a/compute/ecs_service/listener_rules.tf +++ b/compute/ecs_service/listener_rules.tf @@ -4,7 +4,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/outputs.tf b/compute/ecs_service/outputs.tf index a21ab18..3f95214 100644 --- a/compute/ecs_service/outputs.tf +++ b/compute/ecs_service/outputs.tf @@ -249,3 +249,22 @@ output "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 0000000..775536f --- /dev/null +++ b/compute/ecs_service/ravion_domains.tf @@ -0,0 +1,200 @@ +################################################################################ +# 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. The frontend pre-fills this same value +# into the domains list as the default; clearing it opts out. + +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.this) > 0 ? aws_lb_target_group.this[0].arn : ( + length(aws_lb_target_group.tg_1) > 0 ? aws_lb_target_group.tg_1[0].arn : null + ) + ) +} + +# Plan-time authorization guard: a service may only nest its auto-domains under +# a cluster wildcard apex it actually references in its config. Fails the plan +# with a clear message if cluster_parent_fqdn was pointed at another cluster's +# apex the run doesn't reference. The control plane enforces the same rule at +# apply against a signed token claim (Dns:PARENT_APEX_UNAUTHORIZED), so this +# only moves the failure earlier. +data "ravion_parent_apex_check" "cluster" { + count = local.ravion_managed && length(local.wildcard_covered) > 0 ? 1 : 0 + parent_domain_name = local.apex +} + +# Wildcard-covered domains (incl. the auto-FQDN): nest under the cluster +# wildcard. No per-service cert; the cluster `*.` ALIAS routes them. +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 { + # try(...) allows when the check is skipped (count 0) or the apex isn't yet + # resolvable (first apply before the cluster exists) — the apply-time guard + # takes over there. + condition = try(one(data.ravion_parent_apex_check.cluster[*].authorized), true) + error_message = "This service may not nest ${each.value} under ${local.apex}: this deployment does not reference that cluster. Set cluster_parent_fqdn from your own cluster's ravion_cluster_domain_fqdn output." + } + } +} + +# 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 + + role = "instance" + 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)." + } + } +} + +# 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 +} + +# 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). Blue/green controllers +# flip the action externally. +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 + } + } + + action { + type = "forward" + target_group_arn = local.ravion_target_group_arn + } + + 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] + } +} + +# Earlier revisions created a single count-based rule; migrate that instance to +# the first for_each chunk so adopting the chunked layout is not a destroy+create. +moved { + from = aws_lb_listener_rule.ravion[0] + to = aws_lb_listener_rule.ravion["0"] +} diff --git a/compute/ecs_service/target_groups.tf b/compute/ecs_service/target_groups.tf index 44a3bdf..120b0c3 100644 --- a/compute/ecs_service/target_groups.tf +++ b/compute/ecs_service/target_groups.tf @@ -5,6 +5,15 @@ resource "aws_lb_target_group" "this" { count = local.enable_load_balancer && var.deployment_type == "rolling" ? 1 : 0 + # Stable name (the EXACT pre-branch expression) rather than name_prefix: the + # ECS service ignores load_balancer changes and the listener rules ignore + # action, so neither will repoint to a replacement TG. A name_prefix forces a + # one-time ForceNew on existing rolling services, and the old TG can never be + # released ("in use by listener rule"/ECS service) -> apply deadlock. Keeping + # the original stable name means no replacement at all. The substr/28 cap is + # load-bearing: it both matches the currently-deployed name (so no ForceNew) + # and keeps the TG name within ALB's 32-char limit. (Blue/green tg_1/tg_2 are + # already stably named and are flipped, never replaced.) name = "${substr(var.name, 0, min(length(var.name), 28))}-tg" port = var.load_balancer_attachment.target_group.port protocol = var.load_balancer_attachment.target_group.protocol diff --git a/compute/ecs_service/tests/basic.tftest.hcl b/compute/ecs_service/tests/basic.tftest.hcl index 4216a22..2a3acbd 100644 --- a/compute/ecs_service/tests/basic.tftest.hcl +++ b/compute/ecs_service/tests/basic.tftest.hcl @@ -5,6 +5,12 @@ # Mock provider for testing 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/variables.tf b/compute/ecs_service/variables.tf index 13f70cd..ec7c668 100644 --- a/compute/ecs_service/variables.tf +++ b/compute/ecs_service/variables.tf @@ -599,3 +599,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 bec739b..680df02 100644 --- a/compute/ecs_service/versions.tf +++ b/compute/ecs_service/versions.tf @@ -12,6 +12,10 @@ terraform { source = "hashicorp/aws" version = ">= 6.0" } + ravion = { + source = "provider-cf.siddharthsuresh.dev/ravion/ravion" + version = ">= 1.0.0" + } } } diff --git a/hosting/static_site/outputs.tf b/hosting/static_site/outputs.tf index f2fdd22..27ae7dd 100644 --- a/hosting/static_site/outputs.tf +++ b/hosting/static_site/outputs.tf @@ -138,3 +138,13 @@ output "region" { description = "The AWS region where the resources are deployed." value = local.region } + +output "ravion_certificate_arn" { + description = "ACM ARN of the Ravion-managed viewer cert (null unless use_ravion_managed_domains)." + value = var.use_ravion_managed_domains ? ravion_certificate.site[0].cert_arn : null +} + +output "ravion_fqdn" { + description = "Primary FQDN Ravion manages for the site." + value = var.use_ravion_managed_domains ? ravion_certificate.site[0].fqdn : null +} diff --git a/hosting/static_site/ravion_domains.tf b/hosting/static_site/ravion_domains.tf new file mode 100644 index 0000000..9cf44bc --- /dev/null +++ b/hosting/static_site/ravion_domains.tf @@ -0,0 +1,53 @@ +################################################################################ +# Ravion-managed domains for the static site (opt-in) +################################################################################ +# When use_ravion_managed_domains = true, Ravion owns the CloudFront viewer +# certificate + aliases server-side (attached via target_arn = the distribution +# ARN). The cert MUST live in us-east-1 (CloudFront requirement). +# +# domains = [] -> an auto-FQDN -. (instance cert). +# domains = [...] -> a per-site cert over those FQDNs + CUSTOMER routing +# records the user adds (ALIAS to the distribution domain). +# +# IMPORTANT: in Ravion mode, configure var.distributions WITHOUT aliases/ACM +# cert so the cdn submodule leaves the default CloudFront cert in place; Ravion +# swaps the viewer cert + sets aliases via UpdateDistribution. See OPEN_QUESTIONS +# (B-static) for the cdn-submodule ignore_changes follow-up that makes this +# drift-free across applies. + +locals { + ravion_static_enabled = var.use_ravion_managed_domains + ravion_distribution_arn = local.ravion_static_enabled ? try(values(module.cdn.distribution_arns)[0], null) : null + ravion_distribution_domain = local.ravion_static_enabled ? try(values(module.cdn.distribution_domain_names)[0], null) : null +} + +resource "ravion_certificate" "site" { + count = local.ravion_static_enabled ? 1 : 0 + + role = "instance" + domains = length(var.domains) > 0 ? var.domains : null + name = length(var.domains) == 0 ? var.name : null + aws_account_id = var.ravion_aws_account_id + aws_region = "us-east-1" + target_arn = local.ravion_distribution_arn + + lifecycle { + 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 = length(var.domains) <= 10 + error_message = "A static site may declare at most 10 custom domains." + } + } +} + +# CUSTOMER routing records (one per custom FQDN): ALIAS to the distribution. +resource "ravion_domain" "custom" { + for_each = local.ravion_static_enabled ? toset(var.domains) : toset([]) + + name = each.value + target_dns_name = local.ravion_distribution_domain + target_zone_id = "Z2FDTNDATAQYW2" # CloudFront's global hosted zone id +} diff --git a/hosting/static_site/variables.tf b/hosting/static_site/variables.tf index f185fa3..59c2766 100644 --- a/hosting/static_site/variables.tf +++ b/hosting/static_site/variables.tf @@ -430,3 +430,25 @@ 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 = "Have Ravion own the CloudFront viewer cert + aliases (attached server-side). Configure var.distributions without aliases/ACM cert in this mode." + default = false +} + +variable "domains" { + type = list(string) + description = "Customer FQDNs for the site. Empty = a Ravion auto-FQDN. Max 10. Only used when use_ravion_managed_domains = true." + default = [] +} + +variable "ravion_aws_account_id" { + type = string + description = "Ravion AwsAccount row id (aws_*). Required when use_ravion_managed_domains = true." + default = null +} diff --git a/hosting/static_site/versions.tf b/hosting/static_site/versions.tf index f2ea0cd..5c1d482 100644 --- a/hosting/static_site/versions.tf +++ b/hosting/static_site/versions.tf @@ -8,5 +8,9 @@ terraform { source = "hashicorp/aws" version = ">= 6.0" } + ravion = { + source = "provider-cf.siddharthsuresh.dev/ravion/ravion" + version = ">= 1.0.0" + } } } diff --git a/networking/alb/README.md b/networking/alb/README.md index 822819b..f7032bf 100644 --- a/networking/alb/README.md +++ b/networking/alb/README.md @@ -269,6 +269,7 @@ spec: | http_listener_port | The port for the HTTP listener | `number` | `80` | no | | https_listener_port | The port for the HTTPS listener | `number` | `443` | no | | http_to_https_redirect | Redirect HTTP traffic to HTTPS (when both listeners enabled) | `bool` | `true` | no | +| force_http_to_https_redirect | Redirect HTTP->HTTPS even when this module does not own the HTTPS listener (used when a parent module owns port 443) | `bool` | `false` | no | ### SSL/TLS diff --git a/networking/alb/listeners.tf b/networking/alb/listeners.tf index cf0f931..58ec031 100644 --- a/networking/alb/listeners.tf +++ b/networking/alb/listeners.tf @@ -9,10 +9,11 @@ resource "aws_lb_listener" "http" { port = var.http_listener_port protocol = "HTTP" - # If HTTPS is enabled and redirect is enabled, redirect to HTTPS - # Otherwise, return a fixed response + # Redirect to HTTPS when local.redirect_http_to_https (this module owns the + # HTTPS listener, or a parent owns 443 via force_http_to_https_redirect); + # otherwise return a fixed response. dynamic "default_action" { - for_each = var.http_to_https_redirect && local.create_https_listener ? [1] : [] + for_each = local.redirect_http_to_https ? [1] : [] content { type = "redirect" redirect { @@ -24,7 +25,7 @@ resource "aws_lb_listener" "http" { } dynamic "default_action" { - for_each = !var.http_to_https_redirect || !local.create_https_listener ? [1] : [] + for_each = local.redirect_http_to_https ? [] : [1] content { type = "fixed-response" fixed_response { diff --git a/networking/alb/locals.tf b/networking/alb/locals.tf index bb3721f..f797ad1 100644 --- a/networking/alb/locals.tf +++ b/networking/alb/locals.tf @@ -23,6 +23,12 @@ locals { # Listener configuration create_http_listener = var.enable_http_listener create_https_listener = var.enable_https_listener + + # The HTTP listener redirects to HTTPS when redirect is requested and either + # this module owns the HTTPS listener OR a parent module owns 443 + # (force_http_to_https_redirect). Otherwise the HTTP listener returns the + # fixed response. + redirect_http_to_https = var.http_to_https_redirect && (local.create_https_listener || var.force_http_to_https_redirect) } diff --git a/networking/alb/variables.tf b/networking/alb/variables.tf index f3118fd..cc1b59f 100644 --- a/networking/alb/variables.tf +++ b/networking/alb/variables.tf @@ -164,6 +164,12 @@ variable "http_to_https_redirect" { default = true } +variable "force_http_to_https_redirect" { + type = bool + description = "Redirect HTTP->HTTPS even when this module does not own the HTTPS listener (used when the parent owns it)." + default = false +} + ################################################################################ # SSL/TLS ################################################################################