Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
30a2f22
feat(ecs_cluster): Ravion-managed wildcard domain (opt-in)
siddhsuresh May 27, 2026
fb4da3b
feat(ecs_service): Ravion-managed domains (auto-FQDN + custom certs)
siddhsuresh May 27, 2026
67660a5
feat(static_site): Ravion-managed domains (CloudFront, us-east-1)
siddhsuresh May 27, 2026
cddbf96
feat(ecs): Ravion-managed domains support private ALB, not just public
siddhsuresh May 27, 2026
10b1227
use my proxy
siddhsuresh May 27, 2026
e1f338a
fix(ecs_cluster): name the Ravion wildcard from the module instance g…
siddhsuresh May 27, 2026
74d0889
fix(ecs_service): auto-FQDN leaf from module instance given id
siddhsuresh May 27, 2026
0fa3e66
feat(ecs_cluster): point wildcard cert at the cluster ALB
siddhsuresh May 27, 2026
a695922
fix(ecs_service): use name_prefix for TG so attribute changes don't c…
siddhsuresh May 28, 2026
291cac2
feat(static_site): add routing="raw" for object sites
siddhsuresh May 28, 2026
2f78c63
feat: pull ravion provider from CloudFront-hosted registry
siddhsuresh May 28, 2026
638ab14
revert: keep ravion provider source on providers.siddharthsuresh.dev
siddhsuresh May 28, 2026
3a4f1f7
Revert "revert: keep ravion provider source on providers.siddharthsur…
siddhsuresh May 28, 2026
21cf2d5
Revert "feat(static_site): add routing="raw" for object sites"
siddhsuresh May 28, 2026
c315c8f
feat(domains): unify cluster ALB HTTPS listener + surface managed-dom…
siddhsuresh May 29, 2026
64eb79e
feat(domains): collapse ecs_service Mode A/B into per-entry apex clas…
siddhsuresh May 29, 2026
ce3eac1
fix(ecs_service): normalize domain entries before apex classification
siddhsuresh May 29, 2026
1a4577f
feat(ecs_service): reject in-apex non-single-label domains at plan time
siddhsuresh May 29, 2026
9c97fcd
fix(managed-domains): review findings B3/M13/M14/#39/#40
siddhsuresh May 29, 2026
6591dd8
fix(ecs_cluster): create_before_destroy on cluster wildcard cert for …
siddhsuresh May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions compute/ecs_cluster/listeners.tf
Original file line number Diff line number Diff line change
@@ -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_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_certificate.cluster -> aws_lb_listener.public_https
# (uses the cert). ravion_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_certificate.cluster[0].cert_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_certificate.cluster[0].cert_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]
}
32 changes: 22 additions & 10 deletions compute/ecs_cluster/load_balancers.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
42 changes: 38 additions & 4 deletions compute/ecs_cluster/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

################################################################################
Expand Down Expand Up @@ -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
}

################################################################################
Expand Down Expand Up @@ -240,3 +240,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_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_certificate.cluster[0].fqdn : null
}

output "ravion_cluster_cert_arn" {
description = "ACM ARN of the cluster wildcard cert."
value = local.enable_ravion_domain ? ravion_certificate.cluster[0].cert_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
}
51 changes: 51 additions & 0 deletions compute/ecs_cluster/ravion_domains.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
################################################################################
# Ravion-managed cluster domain (opt-in)
################################################################################
# When var.use_ravion_managed_domains = true, Ravion issues ONE wildcard cert
# `*.<name>-<hash>.<ravion-apex>` (+ 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
# `*.<apex>` ALIAS to the cluster ALB so service auto-FQDNs (<svc>.<apex>)
# 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)
}

resource "ravion_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)
aws_account_id = var.ravion_aws_account_id
aws_region = coalesce(var.ravion_aws_region, local.region)

# Ravion publishes a *.<apex> ALIAS to this ALB so service auto-FQDNs
# (<svc>.<apex>) 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."
}
}
}
7 changes: 7 additions & 0 deletions compute/ecs_cluster/tests/basic.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ mock_provider "aws" {
}
}

# Default (BYO) runs never create ravion_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"
Expand Down
Loading
Loading