From 0313d8b1432c97df511008d5a3d69791e712a874 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Thu, 4 Jun 2026 20:26:46 -0700 Subject: [PATCH 01/12] Add TGW egress mode to VPC module --- examples/main.tf | 1 + modules/quilt/main.tf | 2 ++ modules/quilt/variables.tf | 6 ++++++ modules/vpc/main.tf | 37 +++++++++++++++++++++++++++++++++++-- modules/vpc/variables.tf | 6 ++++++ 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/examples/main.tf b/examples/main.tf index f4a953f..4c5c3c1 100644 --- a/examples/main.tf +++ b/examples/main.tf @@ -143,6 +143,7 @@ module "quilt" { # user_security_group = "sg-YOUR-SECURITY-GROUP" # For ALB access # user_subnets = ["subnet-YOUR-USER-1", "subnet-YOUR-USER-2"] # For ALB (if internal = true) # api_endpoint = "vpce-YOUR-VPC-ENDPOINT" # VPC endpoint (if internal = true) + # transit_gateway_id = "tgw-YOUR-TRANSIT-GATEWAY-ID" # For TGW egress when create_new_vpc = true # CloudFormation notifications (optional) # stack_notification_arns = ["arn:aws:sns:YOUR-AWS-REGION:YOUR-ACCOUNT-ID:quilt-notifications"] diff --git a/modules/quilt/main.tf b/modules/quilt/main.tf index 80e3648..bddfc68 100644 --- a/modules/quilt/main.tf +++ b/modules/quilt/main.tf @@ -14,6 +14,8 @@ module "vpc" { cidr = var.cidr internal = var.internal + transit_gateway_id = var.transit_gateway_id + create_new_vpc = var.create_new_vpc existing_api_endpoint = var.api_endpoint existing_vpc_id = var.vpc_id diff --git a/modules/quilt/variables.tf b/modules/quilt/variables.tf index dad6263..9f9a38b 100644 --- a/modules/quilt/variables.tf +++ b/modules/quilt/variables.tf @@ -29,6 +29,12 @@ variable "internal" { description = "If true create an inward ELBv2, else create an internet-facing ELBv2." } +variable "transit_gateway_id" { + type = string + default = null + description = "Transit Gateway ID for private subnet egress when creating a new VPC. If set, NAT gateways and IPv6 egress-only gateways are disabled." +} + variable "db_snapshot_identifier" { type = string nullable = true diff --git a/modules/vpc/main.tf b/modules/vpc/main.tf index e8c928a..70b76b2 100644 --- a/modules/vpc/main.tf +++ b/modules/vpc/main.tf @@ -71,8 +71,41 @@ module "vpc" { enable_dns_hostnames = true enable_dns_support = true - enable_nat_gateway = true - one_nat_gateway_per_az = true + enable_nat_gateway = var.transit_gateway_id == null + one_nat_gateway_per_az = var.transit_gateway_id == null + create_egress_only_igw = var.transit_gateway_id == null +} + +resource "aws_ec2_transit_gateway_vpc_attachment" "egress" { + count = local.new_network_valid && var.transit_gateway_id != null ? 1 : 0 + + subnet_ids = module.vpc.intra_subnets + transit_gateway_id = var.transit_gateway_id + vpc_id = module.vpc.vpc_id + + tags = { + Name = "${var.name}-egress" + } +} + +resource "aws_route" "private_tgw_ipv4_egress" { + count = local.new_network_valid && var.transit_gateway_id != null ? length(module.vpc.private_route_table_ids) : 0 + + route_table_id = module.vpc.private_route_table_ids[count.index] + destination_cidr_block = "0.0.0.0/0" + transit_gateway_id = var.transit_gateway_id + + depends_on = [aws_ec2_transit_gateway_vpc_attachment.egress] +} + +resource "aws_route" "private_tgw_ipv6_egress" { + count = local.new_network_valid && var.transit_gateway_id != null ? length(module.vpc.private_route_table_ids) : 0 + + route_table_id = module.vpc.private_route_table_ids[count.index] + destination_ipv6_cidr_block = "::/0" + transit_gateway_id = var.transit_gateway_id + + depends_on = [aws_ec2_transit_gateway_vpc_attachment.egress] } // Module name no longer accurate (see description); changing name causes tf apply to fail diff --git a/modules/vpc/variables.tf b/modules/vpc/variables.tf index f05b3f3..7544738 100644 --- a/modules/vpc/variables.tf +++ b/modules/vpc/variables.tf @@ -23,6 +23,12 @@ variable "internal" { nullable = false } +variable "transit_gateway_id" { + type = string + default = null + description = "Transit Gateway ID for private subnet egress. If set, NAT gateways and IPv6 egress-only gateways are disabled." +} + variable "existing_vpc_id" { type = string } From 419aa86b9f7d76c5b6bac2768f7d24c818468293 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Thu, 4 Jun 2026 21:10:38 -0700 Subject: [PATCH 02/12] Enable IPv6 on TGW egress attachments --- modules/vpc/main.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/vpc/main.tf b/modules/vpc/main.tf index 70b76b2..dbda11b 100644 --- a/modules/vpc/main.tf +++ b/modules/vpc/main.tf @@ -82,6 +82,7 @@ resource "aws_ec2_transit_gateway_vpc_attachment" "egress" { subnet_ids = module.vpc.intra_subnets transit_gateway_id = var.transit_gateway_id vpc_id = module.vpc.vpc_id + ipv6_support = "enable" tags = { Name = "${var.name}-egress" From 92b34877a284807ade1cad79f08f7c38238d6839 Mon Sep 17 00:00:00 2001 From: "Dr. Ernie Prabhakar" Date: Thu, 4 Jun 2026 23:01:14 -0700 Subject: [PATCH 03/12] Validate transit_gateway_id: format + new-VPC-only Address PR review feedback on the TGW egress mode: - Reject empty/malformed transit_gateway_id via a format validation on both the vpc and quilt module variables. - Fail fast when transit_gateway_id is set with create_new_vpc == false (existing-VPC mode, where it would otherwise be silently ignored) by adding it to the vpc module's existing-network configuration_error precondition. Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/quilt/variables.tf | 6 +++++- modules/vpc/main.tf | 1 + modules/vpc/variables.tf | 6 +++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/modules/quilt/variables.tf b/modules/quilt/variables.tf index 9f9a38b..e85bdf1 100644 --- a/modules/quilt/variables.tf +++ b/modules/quilt/variables.tf @@ -32,7 +32,11 @@ variable "internal" { variable "transit_gateway_id" { type = string default = null - description = "Transit Gateway ID for private subnet egress when creating a new VPC. If set, NAT gateways and IPv6 egress-only gateways are disabled." + description = "Transit Gateway ID for private subnet egress when creating a new VPC. Only supported when create_new_vpc == true. If set, NAT gateways and IPv6 egress-only gateways are disabled." + validation { + condition = var.transit_gateway_id == null || can(regex("^tgw-[0-9a-f]+$", var.transit_gateway_id)) + error_message = "transit_gateway_id must be null or a valid Transit Gateway ID (e.g. tgw-0123456789abcdef0)." + } } variable "db_snapshot_identifier" { diff --git a/modules/vpc/main.tf b/modules/vpc/main.tf index dbda11b..f9eb1cb 100644 --- a/modules/vpc/main.tf +++ b/modules/vpc/main.tf @@ -27,6 +27,7 @@ locals { "user_security_group == null" : var.existing_user_security_group == null, "user_subnets == null" : var.existing_user_subnets == null, "api_endpoint == null" : var.existing_api_endpoint == null, + "transit_gateway_id == null (TGW egress requires create_new_vpc == true)" : var.transit_gateway_id == null, } existing_network_valid = alltrue(values(local.existing_network_requires)) new_network_valid = alltrue(values(local.new_network_requires)) diff --git a/modules/vpc/variables.tf b/modules/vpc/variables.tf index 7544738..b3c2b5b 100644 --- a/modules/vpc/variables.tf +++ b/modules/vpc/variables.tf @@ -26,7 +26,11 @@ variable "internal" { variable "transit_gateway_id" { type = string default = null - description = "Transit Gateway ID for private subnet egress. If set, NAT gateways and IPv6 egress-only gateways are disabled." + description = "Transit Gateway ID for private subnet egress. Only supported when create_new_vpc == true. If set, NAT gateways and IPv6 egress-only gateways are disabled." + validation { + condition = var.transit_gateway_id == null || can(regex("^tgw-[0-9a-f]+$", var.transit_gateway_id)) + error_message = "transit_gateway_id must be null or a valid Transit Gateway ID (e.g. tgw-0123456789abcdef0)." + } } variable "existing_vpc_id" { From 39574578f0bdc8d1e02483711b7a2666427a89bd Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Mon, 8 Jun 2026 17:23:08 +0500 Subject: [PATCH 04/12] Add failing tests for Transit Gateway egress mode These pin the intended behavior of the TGW egress mode and fail against the current validation bug (the `transit_gateway_id == null` check sits in the new-VPC requirement set, so a new VPC + transit_gateway_id wrongly trips configuration_error and the feature is inert): - vpc: new-VPC + transit_gateway_id must plan and create the TGW attachment + IPv4/IPv6 default egress routes; existing-VPC + transit_gateway_id must be rejected. - quilt smoke: new-VPC + transit_gateway_id must plan end-to-end (wrapper now threads transit_gateway_id). The validation fix follows in the next commit. Co-Authored-By: Claude Opus 4.8 --- modules/quilt/tests/smoke/main.tf | 12 +++- modules/quilt/tests/smoke/smoke.tftest.hcl | 14 +++++ modules/vpc/tests/validation.tftest.hcl | 64 ++++++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/modules/quilt/tests/smoke/main.tf b/modules/quilt/tests/smoke/main.tf index 3d9ad09..564fe01 100644 --- a/modules/quilt/tests/smoke/main.tf +++ b/modules/quilt/tests/smoke/main.tf @@ -67,14 +67,20 @@ variable "user_subnets" { default = null } +variable "transit_gateway_id" { + type = string + default = null +} + # New inputs added to the quilt module must be threaded through here, or the # smoke coverage silently narrows (the new input is never exercised). module "quilt" { source = "../../" - name = "quilt-test" - parameters = {} - template_file = "${path.module}/fixtures/quilt.yaml" + name = "quilt-test" + parameters = {} + template_file = "${path.module}/fixtures/quilt.yaml" + transit_gateway_id = var.transit_gateway_id create_new_vpc = var.create_new_vpc internal = var.internal diff --git a/modules/quilt/tests/smoke/smoke.tftest.hcl b/modules/quilt/tests/smoke/smoke.tftest.hcl index 01f4584..358c6d6 100644 --- a/modules/quilt/tests/smoke/smoke.tftest.hcl +++ b/modules/quilt/tests/smoke/smoke.tftest.hcl @@ -30,6 +30,20 @@ run "new_vpc_plans" { } } +run "new_vpc_transit_gateway_plans" { + command = plan + variables { + create_new_vpc = true + internal = false + transit_gateway_id = "tgw-00000000000000000" + } + # TGW egress mode on a new VPC must plan end-to-end through the public module. + assert { + condition = output.stack_name == "quilt-test" + error_message = "The CloudFormation stack must be named after var.name" + } +} + run "new_vpc_internal_plans" { command = plan variables { diff --git a/modules/vpc/tests/validation.tftest.hcl b/modules/vpc/tests/validation.tftest.hcl index 064bf0c..11c8f2d 100644 --- a/modules/vpc/tests/validation.tftest.hcl +++ b/modules/vpc/tests/validation.tftest.hcl @@ -182,3 +182,67 @@ run "existing_vpc_missing_inputs_is_rejected" { # create_new_vpc = false without the required existing_* inputs is incomplete. expect_failures = [output.configuration_error] } + +# --- Transit Gateway egress mode -------------------------------------------- + +run "new_vpc_with_transit_gateway" { + command = plan + + variables { + create_new_vpc = true + internal = false + transit_gateway_id = "tgw-00000000000000000" + existing_vpc_id = null + existing_api_endpoint = null + existing_intra_subnets = null + existing_private_subnets = null + existing_public_subnets = null + existing_user_security_group = null + existing_user_subnets = null + } + + # A new VPC with a transit_gateway_id is the supported TGW egress mode and + # must plan cleanly. + assert { + condition = !strcontains(output.configuration_error, "❌") + error_message = "A new VPC with transit_gateway_id must satisfy every requirement" + } + + # The TGW attachment and both default egress routes must be planned, pointed + # at the supplied gateway. + assert { + condition = length(aws_ec2_transit_gateway_vpc_attachment.egress) == 1 + error_message = "Exactly one TGW VPC attachment must be created" + } + + assert { + condition = aws_ec2_transit_gateway_vpc_attachment.egress[0].transit_gateway_id == "tgw-00000000000000000" + error_message = "The TGW attachment must target the supplied transit_gateway_id" + } + + assert { + condition = length(aws_route.private_tgw_ipv4_egress) > 0 && length(aws_route.private_tgw_ipv6_egress) > 0 + error_message = "IPv4 and IPv6 default egress routes to the TGW must be planned" + } +} + +run "existing_vpc_with_transit_gateway_is_rejected" { + command = plan + + variables { + create_new_vpc = false + internal = false + transit_gateway_id = "tgw-00000000000000000" + existing_vpc_id = "vpc-00000000000000000" + existing_api_endpoint = null + existing_intra_subnets = ["subnet-intra-a", "subnet-intra-b"] + existing_private_subnets = ["subnet-priv-a", "subnet-priv-b"] + existing_public_subnets = ["subnet-pub-a", "subnet-pub-b"] + existing_user_security_group = "sg-00000000000000000" + existing_user_subnets = null + } + + # transit_gateway_id is only supported with create_new_vpc = true; combining + # it with an existing VPC must fail fast rather than silently ignore the id. + expect_failures = [output.configuration_error] +} From e6d7e63914006f37998fdf89a526f4c66019f267 Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Mon, 8 Jun 2026 17:54:18 +0500 Subject: [PATCH 05/12] Fix inert TGW egress mode: validate transit_gateway_id on the existing-VPC path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transit_gateway_id == null check was in new_network_requires, so creating a new VPC with a transit_gateway_id (the supported egress mode) failed the new-network requirement set and tripped configuration_error — the feature could never be applied. It also left existing-VPC + transit_gateway_id silently accepted rather than rejected. Move the check to existing_network_requires, which expresses the actual intent ("TGW egress requires create_new_vpc == true"): - new VPC + transit_gateway_id now plans and creates the attachment + routes; - existing VPC + transit_gateway_id is rejected during plan; - existing VPC without a TGW is unaffected (transit_gateway_id == null holds). Turns the failing tests from the previous commit green. Co-Authored-By: Claude Opus 4.8 --- modules/vpc/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/vpc/main.tf b/modules/vpc/main.tf index f9eb1cb..7bc9d71 100644 --- a/modules/vpc/main.tf +++ b/modules/vpc/main.tf @@ -17,6 +17,7 @@ locals { "user_security_group (required)" : var.existing_user_security_group != null, "user_subnets (required if var.internal == true and var.create_new_vpc == false, else must be null)" : (var.internal && !var.create_new_vpc) == (var.existing_user_subnets != null) "api_endpoint (required if var.internal == true, else must be null)" : var.internal == (var.existing_api_endpoint != null), + "transit_gateway_id == null (TGW egress requires create_new_vpc == true)" : var.transit_gateway_id == null, } new_network_requires = { "create_new_vpc == true" : var.create_new_vpc == true, @@ -27,7 +28,6 @@ locals { "user_security_group == null" : var.existing_user_security_group == null, "user_subnets == null" : var.existing_user_subnets == null, "api_endpoint == null" : var.existing_api_endpoint == null, - "transit_gateway_id == null (TGW egress requires create_new_vpc == true)" : var.transit_gateway_id == null, } existing_network_valid = alltrue(values(local.existing_network_requires)) new_network_valid = alltrue(values(local.new_network_requires)) From 9084d6a18d6587a64ba25bac60fc8297521e6c94 Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Mon, 8 Jun 2026 18:32:16 +0500 Subject: [PATCH 06/12] Make IPv6 egress via TGW opt-in (transit_gateway_ipv6_egress) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing the private subnets' ::/0 at the TGW unconditionally assumed the TGW carries IPv6. If it doesn't, IPv6 traffic is black-holed (worse than no route, which lets IPv4 fall back cleanly) — an assumption the general-purpose module should not bake in. Add transit_gateway_ipv6_egress (bool, default false) on the vpc and quilt modules. When false (default), no ::/0 -> TGW route is created and the attachment advertises ipv6_support = "disable"; the established IPv4 control-plane egress is unaffected. Operators whose TGW does carry IPv6 opt in. Tests cover both: IPv6 off (no v6 route, attachment ipv6_support disabled) and IPv6 on (v6 route present, ipv6_support enabled). Co-Authored-By: Claude Opus 4.8 --- modules/quilt/main.tf | 3 +- modules/quilt/tests/smoke/main.tf | 14 ++++-- modules/quilt/tests/smoke/smoke.tftest.hcl | 15 +++++++ modules/quilt/variables.tf | 6 +++ modules/vpc/main.tf | 4 +- modules/vpc/tests/validation.tftest.hcl | 51 ++++++++++++++++++++-- modules/vpc/variables.tf | 6 +++ 7 files changed, 88 insertions(+), 11 deletions(-) diff --git a/modules/quilt/main.tf b/modules/quilt/main.tf index bddfc68..e0ce458 100644 --- a/modules/quilt/main.tf +++ b/modules/quilt/main.tf @@ -14,7 +14,8 @@ module "vpc" { cidr = var.cidr internal = var.internal - transit_gateway_id = var.transit_gateway_id + transit_gateway_id = var.transit_gateway_id + transit_gateway_ipv6_egress = var.transit_gateway_ipv6_egress create_new_vpc = var.create_new_vpc existing_api_endpoint = var.api_endpoint diff --git a/modules/quilt/tests/smoke/main.tf b/modules/quilt/tests/smoke/main.tf index 564fe01..d75e1ed 100644 --- a/modules/quilt/tests/smoke/main.tf +++ b/modules/quilt/tests/smoke/main.tf @@ -72,15 +72,21 @@ variable "transit_gateway_id" { default = null } +variable "transit_gateway_ipv6_egress" { + type = bool + default = false +} + # New inputs added to the quilt module must be threaded through here, or the # smoke coverage silently narrows (the new input is never exercised). module "quilt" { source = "../../" - name = "quilt-test" - parameters = {} - template_file = "${path.module}/fixtures/quilt.yaml" - transit_gateway_id = var.transit_gateway_id + name = "quilt-test" + parameters = {} + template_file = "${path.module}/fixtures/quilt.yaml" + transit_gateway_id = var.transit_gateway_id + transit_gateway_ipv6_egress = var.transit_gateway_ipv6_egress create_new_vpc = var.create_new_vpc internal = var.internal diff --git a/modules/quilt/tests/smoke/smoke.tftest.hcl b/modules/quilt/tests/smoke/smoke.tftest.hcl index 358c6d6..a35e1d2 100644 --- a/modules/quilt/tests/smoke/smoke.tftest.hcl +++ b/modules/quilt/tests/smoke/smoke.tftest.hcl @@ -44,6 +44,21 @@ run "new_vpc_transit_gateway_plans" { } } +run "new_vpc_transit_gateway_ipv6_plans" { + command = plan + variables { + create_new_vpc = true + internal = false + transit_gateway_id = "tgw-00000000000000000" + transit_gateway_ipv6_egress = true + } + # TGW egress with IPv6 opted in must also plan end-to-end. + assert { + condition = output.stack_name == "quilt-test" + error_message = "The CloudFormation stack must be named after var.name" + } +} + run "new_vpc_internal_plans" { command = plan variables { diff --git a/modules/quilt/variables.tf b/modules/quilt/variables.tf index e85bdf1..e563838 100644 --- a/modules/quilt/variables.tf +++ b/modules/quilt/variables.tf @@ -39,6 +39,12 @@ variable "transit_gateway_id" { } } +variable "transit_gateway_ipv6_egress" { + type = bool + default = false + description = "When transit_gateway_id is set, also route the private subnets' IPv6 default route (::/0) through the Transit Gateway. Leave false unless the Transit Gateway is configured for IPv6 egress; otherwise IPv6 traffic would be black-holed (with no route, IPv4 still falls back cleanly). No effect when transit_gateway_id is null." +} + variable "db_snapshot_identifier" { type = string nullable = true diff --git a/modules/vpc/main.tf b/modules/vpc/main.tf index 7bc9d71..2875b97 100644 --- a/modules/vpc/main.tf +++ b/modules/vpc/main.tf @@ -83,7 +83,7 @@ resource "aws_ec2_transit_gateway_vpc_attachment" "egress" { subnet_ids = module.vpc.intra_subnets transit_gateway_id = var.transit_gateway_id vpc_id = module.vpc.vpc_id - ipv6_support = "enable" + ipv6_support = var.transit_gateway_ipv6_egress ? "enable" : "disable" tags = { Name = "${var.name}-egress" @@ -101,7 +101,7 @@ resource "aws_route" "private_tgw_ipv4_egress" { } resource "aws_route" "private_tgw_ipv6_egress" { - count = local.new_network_valid && var.transit_gateway_id != null ? length(module.vpc.private_route_table_ids) : 0 + count = local.new_network_valid && var.transit_gateway_id != null && var.transit_gateway_ipv6_egress ? length(module.vpc.private_route_table_ids) : 0 route_table_id = module.vpc.private_route_table_ids[count.index] destination_ipv6_cidr_block = "::/0" diff --git a/modules/vpc/tests/validation.tftest.hcl b/modules/vpc/tests/validation.tftest.hcl index 11c8f2d..f37a997 100644 --- a/modules/vpc/tests/validation.tftest.hcl +++ b/modules/vpc/tests/validation.tftest.hcl @@ -208,8 +208,8 @@ run "new_vpc_with_transit_gateway" { error_message = "A new VPC with transit_gateway_id must satisfy every requirement" } - # The TGW attachment and both default egress routes must be planned, pointed - # at the supplied gateway. + # The TGW attachment and the IPv4 default egress route must be planned, + # pointed at the supplied gateway. assert { condition = length(aws_ec2_transit_gateway_vpc_attachment.egress) == 1 error_message = "Exactly one TGW VPC attachment must be created" @@ -221,8 +221,51 @@ run "new_vpc_with_transit_gateway" { } assert { - condition = length(aws_route.private_tgw_ipv4_egress) > 0 && length(aws_route.private_tgw_ipv6_egress) > 0 - error_message = "IPv4 and IPv6 default egress routes to the TGW must be planned" + condition = length(aws_route.private_tgw_ipv4_egress) > 0 + error_message = "IPv4 default egress routes to the TGW must be planned" + } + + # IPv6 egress via the TGW is opt-in (transit_gateway_ipv6_egress, default + # false). With it off, no ::/0 route is created and the attachment does not + # advertise IPv6 support, so IPv6 traffic is not black-holed at the TGW. + assert { + condition = length(aws_route.private_tgw_ipv6_egress) == 0 + error_message = "No IPv6 egress route should be planned when transit_gateway_ipv6_egress is false" + } + + assert { + condition = aws_ec2_transit_gateway_vpc_attachment.egress[0].ipv6_support == "disable" + error_message = "The TGW attachment must not advertise IPv6 support when transit_gateway_ipv6_egress is false" + } +} + +run "new_vpc_with_transit_gateway_ipv6_egress" { + command = plan + + variables { + create_new_vpc = true + internal = false + transit_gateway_id = "tgw-00000000000000000" + transit_gateway_ipv6_egress = true + existing_vpc_id = null + existing_api_endpoint = null + existing_intra_subnets = null + existing_private_subnets = null + existing_public_subnets = null + existing_user_security_group = null + existing_user_subnets = null + } + + # Opting in routes the IPv6 default route through the TGW and enables IPv6 + # support on the attachment. + assert { + condition = length(aws_route.private_tgw_ipv6_egress) > 0 + error_message = "IPv6 egress routes to the TGW must be planned when transit_gateway_ipv6_egress is true" + } + + assert { + condition = aws_ec2_transit_gateway_vpc_attachment.egress[0].ipv6_support == "enable" + error_message = "The TGW attachment must advertise IPv6 support when transit_gateway_ipv6_egress is true" } } diff --git a/modules/vpc/variables.tf b/modules/vpc/variables.tf index b3c2b5b..8721386 100644 --- a/modules/vpc/variables.tf +++ b/modules/vpc/variables.tf @@ -33,6 +33,12 @@ variable "transit_gateway_id" { } } +variable "transit_gateway_ipv6_egress" { + type = bool + default = false + description = "When transit_gateway_id is set, also route the private subnets' IPv6 default route (::/0) through the Transit Gateway. Leave false unless the Transit Gateway is configured for IPv6 egress; otherwise IPv6 traffic would be black-holed (with no route, IPv4 still falls back cleanly). No effect when transit_gateway_id is null." +} + variable "existing_vpc_id" { type = string } From 4baa74ebed3b519093c9a4ca7d1010ac601f40fc Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Tue, 9 Jun 2026 12:35:43 +0500 Subject: [PATCH 07/12] Gate TGW egress on enable_transit_gateway bool (allow computed id) The toggle was `transit_gateway_id != null`, used in `count`. That forbids a transit_gateway_id known only after apply (e.g. a TGW created in the same configuration): count must be known at plan, so a computed id raises "Invalid count argument". This is the idiomatic terraform-aws-modules pattern (create_*/enable_* bool next to the value param, like enable_nat_gateway). Add `enable_transit_gateway` (bool, default false) on the vpc and quilt modules; gate the attachment/route counts and the NAT/EOIGW disable on it. transit_gateway_id is now the value (required when enabled, may be computed), guarded by a precondition on the attachment. Existing-VPC + enabled is still rejected via the configuration_error precondition. Tests updated to set enable_transit_gateway; verified a computed transit_gateway_id (same-config TGW) now plans cleanly. Example updated. Co-Authored-By: Claude Opus 4.8 --- examples/main.tf | 4 +++- modules/quilt/main.tf | 1 + modules/quilt/tests/smoke/main.tf | 6 ++++++ modules/quilt/tests/smoke/smoke.tftest.hcl | 8 +++++--- modules/quilt/variables.tf | 10 +++++++-- modules/vpc/main.tf | 24 +++++++++++++++------- modules/vpc/tests/validation.tftest.hcl | 10 ++++++--- modules/vpc/variables.tf | 10 +++++++-- 8 files changed, 55 insertions(+), 18 deletions(-) diff --git a/examples/main.tf b/examples/main.tf index 923e28c..37eb463 100644 --- a/examples/main.tf +++ b/examples/main.tf @@ -137,7 +137,9 @@ module "quilt" { # user_security_group = "sg-YOUR-SECURITY-GROUP" # For ALB access # user_subnets = ["subnet-YOUR-USER-1", "subnet-YOUR-USER-2"] # For ALB (if internal = true) # api_endpoint = "vpce-YOUR-VPC-ENDPOINT" # VPC endpoint (if internal = true) - # transit_gateway_id = "tgw-YOUR-TRANSIT-GATEWAY-ID" # For TGW egress when create_new_vpc = true + # enable_transit_gateway = true # Route private-subnet egress via a TGW instead of NAT (create_new_vpc = true only) + # transit_gateway_id = "tgw-YOUR-TRANSIT-GATEWAY-ID" # Required when enable_transit_gateway = true; the TGW must route to the internet and back + # transit_gateway_ipv6_egress = true # Only if the TGW carries IPv6 egress (otherwise IPv6 is left on IPv4 fallback) # CloudFormation notifications (optional) # stack_notification_arns = ["arn:aws:sns:YOUR-AWS-REGION:YOUR-ACCOUNT-ID:quilt-notifications"] diff --git a/modules/quilt/main.tf b/modules/quilt/main.tf index e0ce458..d8bf322 100644 --- a/modules/quilt/main.tf +++ b/modules/quilt/main.tf @@ -14,6 +14,7 @@ module "vpc" { cidr = var.cidr internal = var.internal + enable_transit_gateway = var.enable_transit_gateway transit_gateway_id = var.transit_gateway_id transit_gateway_ipv6_egress = var.transit_gateway_ipv6_egress diff --git a/modules/quilt/tests/smoke/main.tf b/modules/quilt/tests/smoke/main.tf index 1c18ef7..c8c287c 100644 --- a/modules/quilt/tests/smoke/main.tf +++ b/modules/quilt/tests/smoke/main.tf @@ -66,6 +66,11 @@ variable "user_subnets" { default = null } +variable "enable_transit_gateway" { + type = bool + default = false +} + variable "transit_gateway_id" { type = string default = null @@ -84,6 +89,7 @@ module "quilt" { name = "quilt-test" parameters = {} template_file = "${path.module}/fixtures/quilt.yaml" + enable_transit_gateway = var.enable_transit_gateway transit_gateway_id = var.transit_gateway_id transit_gateway_ipv6_egress = var.transit_gateway_ipv6_egress diff --git a/modules/quilt/tests/smoke/smoke.tftest.hcl b/modules/quilt/tests/smoke/smoke.tftest.hcl index be476d1..3c169ab 100644 --- a/modules/quilt/tests/smoke/smoke.tftest.hcl +++ b/modules/quilt/tests/smoke/smoke.tftest.hcl @@ -35,9 +35,10 @@ run "new_vpc_plans" { run "new_vpc_transit_gateway_plans" { command = plan variables { - create_new_vpc = true - internal = false - transit_gateway_id = "tgw-00000000000000000" + create_new_vpc = true + internal = false + enable_transit_gateway = true + transit_gateway_id = "tgw-00000000000000000" } # TGW egress mode on a new VPC must plan end-to-end through the public module. assert { @@ -51,6 +52,7 @@ run "new_vpc_transit_gateway_ipv6_plans" { variables { create_new_vpc = true internal = false + enable_transit_gateway = true transit_gateway_id = "tgw-00000000000000000" transit_gateway_ipv6_egress = true } diff --git a/modules/quilt/variables.tf b/modules/quilt/variables.tf index e563838..10a0a72 100644 --- a/modules/quilt/variables.tf +++ b/modules/quilt/variables.tf @@ -29,10 +29,16 @@ variable "internal" { description = "If true create an inward ELBv2, else create an internet-facing ELBv2." } +variable "enable_transit_gateway" { + type = bool + default = false + description = "Route private subnet egress through a Transit Gateway instead of NAT gateways. Only supported when create_new_vpc == true. When true, transit_gateway_id is required, and NAT gateways and the IPv6 egress-only gateway are disabled. (Toggle is a separate bool so transit_gateway_id may be a value known only after apply, e.g. a TGW created in the same configuration.)" +} + variable "transit_gateway_id" { type = string default = null - description = "Transit Gateway ID for private subnet egress when creating a new VPC. Only supported when create_new_vpc == true. If set, NAT gateways and IPv6 egress-only gateways are disabled." + description = "Transit Gateway ID for private subnet egress when creating a new VPC. Required when enable_transit_gateway == true; may be a computed value (e.g. a TGW created in the same configuration)." validation { condition = var.transit_gateway_id == null || can(regex("^tgw-[0-9a-f]+$", var.transit_gateway_id)) error_message = "transit_gateway_id must be null or a valid Transit Gateway ID (e.g. tgw-0123456789abcdef0)." @@ -42,7 +48,7 @@ variable "transit_gateway_id" { variable "transit_gateway_ipv6_egress" { type = bool default = false - description = "When transit_gateway_id is set, also route the private subnets' IPv6 default route (::/0) through the Transit Gateway. Leave false unless the Transit Gateway is configured for IPv6 egress; otherwise IPv6 traffic would be black-holed (with no route, IPv4 still falls back cleanly). No effect when transit_gateway_id is null." + description = "When enable_transit_gateway is true, also route the private subnets' IPv6 default route (::/0) through the Transit Gateway. Leave false unless the Transit Gateway is configured for IPv6 egress; otherwise IPv6 traffic would be black-holed (with no route, IPv4 still falls back cleanly). No effect when enable_transit_gateway is false." } variable "db_snapshot_identifier" { diff --git a/modules/vpc/main.tf b/modules/vpc/main.tf index 2875b97..604b16a 100644 --- a/modules/vpc/main.tf +++ b/modules/vpc/main.tf @@ -17,7 +17,7 @@ locals { "user_security_group (required)" : var.existing_user_security_group != null, "user_subnets (required if var.internal == true and var.create_new_vpc == false, else must be null)" : (var.internal && !var.create_new_vpc) == (var.existing_user_subnets != null) "api_endpoint (required if var.internal == true, else must be null)" : var.internal == (var.existing_api_endpoint != null), - "transit_gateway_id == null (TGW egress requires create_new_vpc == true)" : var.transit_gateway_id == null, + "enable_transit_gateway == false (TGW egress requires create_new_vpc == true)" : var.enable_transit_gateway == false, } new_network_requires = { "create_new_vpc == true" : var.create_new_vpc == true, @@ -72,13 +72,16 @@ module "vpc" { enable_dns_hostnames = true enable_dns_support = true - enable_nat_gateway = var.transit_gateway_id == null - one_nat_gateway_per_az = var.transit_gateway_id == null - create_egress_only_igw = var.transit_gateway_id == null + enable_nat_gateway = !var.enable_transit_gateway + one_nat_gateway_per_az = !var.enable_transit_gateway + create_egress_only_igw = !var.enable_transit_gateway } resource "aws_ec2_transit_gateway_vpc_attachment" "egress" { - count = local.new_network_valid && var.transit_gateway_id != null ? 1 : 0 + # Gate on the bool, not on transit_gateway_id != null: count must be known at + # plan time, and transit_gateway_id may be a computed value (e.g. a TGW + # created in the same configuration). + count = local.new_network_valid && var.enable_transit_gateway ? 1 : 0 subnet_ids = module.vpc.intra_subnets transit_gateway_id = var.transit_gateway_id @@ -88,10 +91,17 @@ resource "aws_ec2_transit_gateway_vpc_attachment" "egress" { tags = { Name = "${var.name}-egress" } + + lifecycle { + precondition { + condition = var.transit_gateway_id != null + error_message = "transit_gateway_id is required when enable_transit_gateway is true." + } + } } resource "aws_route" "private_tgw_ipv4_egress" { - count = local.new_network_valid && var.transit_gateway_id != null ? length(module.vpc.private_route_table_ids) : 0 + count = local.new_network_valid && var.enable_transit_gateway ? length(module.vpc.private_route_table_ids) : 0 route_table_id = module.vpc.private_route_table_ids[count.index] destination_cidr_block = "0.0.0.0/0" @@ -101,7 +111,7 @@ resource "aws_route" "private_tgw_ipv4_egress" { } resource "aws_route" "private_tgw_ipv6_egress" { - count = local.new_network_valid && var.transit_gateway_id != null && var.transit_gateway_ipv6_egress ? length(module.vpc.private_route_table_ids) : 0 + count = local.new_network_valid && var.enable_transit_gateway && var.transit_gateway_ipv6_egress ? length(module.vpc.private_route_table_ids) : 0 route_table_id = module.vpc.private_route_table_ids[count.index] destination_ipv6_cidr_block = "::/0" diff --git a/modules/vpc/tests/validation.tftest.hcl b/modules/vpc/tests/validation.tftest.hcl index f37a997..d826abf 100644 --- a/modules/vpc/tests/validation.tftest.hcl +++ b/modules/vpc/tests/validation.tftest.hcl @@ -191,6 +191,7 @@ run "new_vpc_with_transit_gateway" { variables { create_new_vpc = true internal = false + enable_transit_gateway = true transit_gateway_id = "tgw-00000000000000000" existing_vpc_id = null existing_api_endpoint = null @@ -201,7 +202,7 @@ run "new_vpc_with_transit_gateway" { existing_user_subnets = null } - # A new VPC with a transit_gateway_id is the supported TGW egress mode and + # A new VPC with enable_transit_gateway is the supported TGW egress mode and # must plan cleanly. assert { condition = !strcontains(output.configuration_error, "❌") @@ -245,6 +246,7 @@ run "new_vpc_with_transit_gateway_ipv6_egress" { variables { create_new_vpc = true internal = false + enable_transit_gateway = true transit_gateway_id = "tgw-00000000000000000" transit_gateway_ipv6_egress = true existing_vpc_id = null @@ -275,6 +277,7 @@ run "existing_vpc_with_transit_gateway_is_rejected" { variables { create_new_vpc = false internal = false + enable_transit_gateway = true transit_gateway_id = "tgw-00000000000000000" existing_vpc_id = "vpc-00000000000000000" existing_api_endpoint = null @@ -285,7 +288,8 @@ run "existing_vpc_with_transit_gateway_is_rejected" { existing_user_subnets = null } - # transit_gateway_id is only supported with create_new_vpc = true; combining - # it with an existing VPC must fail fast rather than silently ignore the id. + # enable_transit_gateway is only supported with create_new_vpc = true; + # combining it with an existing VPC must fail fast rather than silently + # ignore the request. expect_failures = [output.configuration_error] } diff --git a/modules/vpc/variables.tf b/modules/vpc/variables.tf index 8721386..06567f6 100644 --- a/modules/vpc/variables.tf +++ b/modules/vpc/variables.tf @@ -23,10 +23,16 @@ variable "internal" { nullable = false } +variable "enable_transit_gateway" { + type = bool + default = false + description = "Route private subnet egress through a Transit Gateway instead of NAT gateways. Only supported when create_new_vpc == true. When true, transit_gateway_id is required, and NAT gateways and the IPv6 egress-only gateway are disabled. (Toggle is a separate bool so transit_gateway_id may be a value known only after apply, e.g. a TGW created in the same configuration.)" +} + variable "transit_gateway_id" { type = string default = null - description = "Transit Gateway ID for private subnet egress. Only supported when create_new_vpc == true. If set, NAT gateways and IPv6 egress-only gateways are disabled." + description = "Transit Gateway ID for private subnet egress. Required when enable_transit_gateway == true; may be a computed value (e.g. a TGW created in the same configuration)." validation { condition = var.transit_gateway_id == null || can(regex("^tgw-[0-9a-f]+$", var.transit_gateway_id)) error_message = "transit_gateway_id must be null or a valid Transit Gateway ID (e.g. tgw-0123456789abcdef0)." @@ -36,7 +42,7 @@ variable "transit_gateway_id" { variable "transit_gateway_ipv6_egress" { type = bool default = false - description = "When transit_gateway_id is set, also route the private subnets' IPv6 default route (::/0) through the Transit Gateway. Leave false unless the Transit Gateway is configured for IPv6 egress; otherwise IPv6 traffic would be black-holed (with no route, IPv4 still falls back cleanly). No effect when transit_gateway_id is null." + description = "When enable_transit_gateway is true, also route the private subnets' IPv6 default route (::/0) through the Transit Gateway. Leave false unless the Transit Gateway is configured for IPv6 egress; otherwise IPv6 traffic would be black-holed (with no route, IPv4 still falls back cleanly). No effect when enable_transit_gateway is false." } variable "existing_vpc_id" { From 8ac2910aa38e0a0cd1b1598506a5f04c6b6ac318 Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Tue, 9 Jun 2026 15:29:52 +0500 Subject: [PATCH 08/12] Document Transit Gateway egress mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: "Transit Gateway egress" section — what it does, how to enable (enable_transit_gateway + transit_gateway_id, computed id allowed), the operator-side TGW prerequisites (RAM share/accept, egress + return routes), CIDR-uniqueness requirement, IPv6 opt-in, and reversibility. - VARIABLES: rows for enable_transit_gateway, transit_gateway_id, transit_gateway_ipv6_egress. - CHANGELOG: [Unreleased] entry for the feature. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ VARIABLES.md | 3 +++ 3 files changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6597b10..714ed54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Optional release notice. ## [Unreleased] - YYYY-MM-DD +- [Added] Transit Gateway egress mode for new VPCs: set `enable_transit_gateway = true` (+ `transit_gateway_id`) to route private-subnet egress through a Transit Gateway instead of NAT gateways; IPv6 egress is opt-in via `transit_gateway_ipv6_egress`. See [Transit Gateway egress](README.md#transit-gateway-egress) ([#115](https://github.com/quiltdata/iac/pull/115)) + ## [1.7.2] - 2026-06-08 - [Fixed] Bump `modules/cnames` AWS provider constraint from `~> 5.0` to `~> 6.0` so it resolves alongside the `vpc` module's `aws >= 6.28` requirement — using `quilt` + `cnames` in one root previously failed `terraform init` ([#117](https://github.com/quiltdata/iac/pull/117)) diff --git a/README.md b/README.md index 6cd7d3f..c196166 100644 --- a/README.md +++ b/README.md @@ -771,6 +771,56 @@ resource "aws_vpc_endpoint" "api_gateway_endpoint" { } ``` +### Transit Gateway egress + +By default a new Quilt VPC reaches the internet through Quilt-created NAT +gateways. If you operate a Transit Gateway (TGW) as your egress boundary, set +`enable_transit_gateway = true` (only with `create_new_vpc = true`). Quilt still +creates the VPC, subnets, and endpoints, but instead disables the NAT gateways +and the IPv6 egress-only IGW, attaches the VPC to your TGW (in the intra +subnets), and points each private route table's default route at the TGW. The +S3 gateway endpoint is unchanged, so bulk S3 traffic stays on the endpoint and +does **not** traverse the TGW — only genuinely external egress does. + +```hcl +module "quilt" { + # ... + create_new_vpc = true + enable_transit_gateway = true + transit_gateway_id = "tgw-0123456789abcdef0" # an existing TGW, or one created in this same config + # transit_gateway_ipv6_egress = true # only if your TGW carries IPv6 egress +} +``` + +`transit_gateway_id` may be a value known only after apply (e.g. a TGW you +create in the same Terraform configuration) — the toggle is the separate +`enable_transit_gateway` bool, so this does not break planning. + +**You must provide the egress path.** Quilt owns only the VPC→TGW leg. Before +apply, your TGW must: + +- be reachable from the deployment account — share it via AWS RAM and accept + the VPC attachment (or enable auto-accept) if the TGW lives in another + account; +- have route tables that forward the VPC's egress out to the internet (e.g. via + a central egress VPC / NAT) **and** route return traffic back to the VPC's + CIDR. + +**CIDR uniqueness:** a TGW cannot route between overlapping CIDRs, so any VPCs +attached to the same TGW must have non-overlapping ranges. Set `cidr` +accordingly if more than one Quilt stack shares a TGW (the default is +`10.0.0.0/16`). + +**IPv6** egress through the TGW is opt-in (`transit_gateway_ipv6_egress`, +default `false`). Enable it only if your TGW actually carries IPv6 egress; +otherwise leave it off — IPv6 then has no default route and falls back to IPv4, +rather than being black-holed at a TGW that can't route it. + +**Reversibility:** removing `enable_transit_gateway` (or setting it `false`) +restores the NAT gateways and IPv6 egress-only IGW. Toggling it on or off for an +already-deployed VPC recreates/destroys NAT gateways and their Elastic IPs and +briefly interrupts egress, so do it in a maintenance window. + ### Profile You may wish to set a specific AWS profile before executing `terraform` commands. diff --git a/VARIABLES.md b/VARIABLES.md index 6014c82..3e7c366 100644 --- a/VARIABLES.md +++ b/VARIABLES.md @@ -26,6 +26,9 @@ This document provides comprehensive documentation for all variables available i | `user_subnets` | `list(string)` | `null` | ALB subnet IDs (exactly 2 required for internal ALB with existing VPC) | | `user_security_group` | `string` | `null` | Security group ID for ALB access (required for existing VPC) | | `api_endpoint` | `string` | `null` | VPC endpoint ID for API Gateway (required for internal ALB with existing VPC) | +| `enable_transit_gateway` | `bool` | `false` | Route private-subnet egress through a Transit Gateway instead of NAT gateways (`create_new_vpc = true` only). Disables NAT + the IPv6 egress-only IGW; requires `transit_gateway_id`. See [Transit Gateway egress](README.md#transit-gateway-egress). | +| `transit_gateway_id` | `string` | `null` | Transit Gateway to attach to (required when `enable_transit_gateway = true`). May be a value known only after apply (e.g. a TGW created in the same configuration). | +| `transit_gateway_ipv6_egress` | `bool` | `false` | Also route IPv6 (`::/0`) egress through the TGW. Leave off unless the TGW carries IPv6 egress, otherwise IPv6 traffic would be black-holed. | ### Database Configuration Variables From 91645b65ccda515cd766628cf943967fef97f7cf Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Tue, 9 Jun 2026 15:52:23 +0500 Subject: [PATCH 09/12] Reframe IPv6-egress docs: explain the real fast/slow mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous wording implied IPv6 "falls back to IPv4" cleanly. Correct it: with transit_gateway_ipv6_egress off there is no IPv6 default route, so an IPv6 connection attempt fails immediately (ENETUNREACH) and the client uses IPv4 with no delay. The slow case is the opposite — enabling it against a TGW that does not carry IPv6 points ::/0 at a black hole, and clients without Happy Eyeballs (e.g. Python requests/urllib3) stall on the connection timeout. So the warning belongs on enabling it, not on the (safe) default-off path. Co-Authored-By: Claude Opus 4.8 --- README.md | 10 +++++++--- modules/quilt/variables.tf | 2 +- modules/vpc/variables.tf | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c196166..cd4b682 100644 --- a/README.md +++ b/README.md @@ -812,9 +812,13 @@ accordingly if more than one Quilt stack shares a TGW (the default is `10.0.0.0/16`). **IPv6** egress through the TGW is opt-in (`transit_gateway_ipv6_egress`, -default `false`). Enable it only if your TGW actually carries IPv6 egress; -otherwise leave it off — IPv6 then has no default route and falls back to IPv4, -rather than being black-holed at a TGW that can't route it. +default `false`). The VPC is dual-stack, so set this `true` **only if your TGW +actually carries IPv6 egress**: pointing `::/0` at a TGW that can't route IPv6 +black-holes those packets, and clients without Happy Eyeballs (e.g. Python's +`requests`/`urllib3`) then stall on the connection timeout before falling back +to IPv4. Left `false`, the new VPC has no IPv6 default route, so an IPv6 +attempt fails immediately (`ENETUNREACH`) and the client uses IPv4 with no +delay. **Reversibility:** removing `enable_transit_gateway` (or setting it `false`) restores the NAT gateways and IPv6 egress-only IGW. Toggling it on or off for an diff --git a/modules/quilt/variables.tf b/modules/quilt/variables.tf index 10a0a72..ba39e28 100644 --- a/modules/quilt/variables.tf +++ b/modules/quilt/variables.tf @@ -48,7 +48,7 @@ variable "transit_gateway_id" { variable "transit_gateway_ipv6_egress" { type = bool default = false - description = "When enable_transit_gateway is true, also route the private subnets' IPv6 default route (::/0) through the Transit Gateway. Leave false unless the Transit Gateway is configured for IPv6 egress; otherwise IPv6 traffic would be black-holed (with no route, IPv4 still falls back cleanly). No effect when enable_transit_gateway is false." + description = "When enable_transit_gateway is true, also route IPv6 (::/0) egress through the Transit Gateway. Set true only if the Transit Gateway carries IPv6 egress: pointing ::/0 at a TGW that can't route IPv6 black-holes the traffic and stalls clients without Happy Eyeballs (e.g. Python requests/urllib3) on the connection timeout. Left false (default), the VPC has no IPv6 default route, so IPv6 attempts fail immediately and clients use IPv4 with no delay. No effect when enable_transit_gateway is false." } variable "db_snapshot_identifier" { diff --git a/modules/vpc/variables.tf b/modules/vpc/variables.tf index 06567f6..2dfffcb 100644 --- a/modules/vpc/variables.tf +++ b/modules/vpc/variables.tf @@ -42,7 +42,7 @@ variable "transit_gateway_id" { variable "transit_gateway_ipv6_egress" { type = bool default = false - description = "When enable_transit_gateway is true, also route the private subnets' IPv6 default route (::/0) through the Transit Gateway. Leave false unless the Transit Gateway is configured for IPv6 egress; otherwise IPv6 traffic would be black-holed (with no route, IPv4 still falls back cleanly). No effect when enable_transit_gateway is false." + description = "When enable_transit_gateway is true, also route IPv6 (::/0) egress through the Transit Gateway. Set true only if the Transit Gateway carries IPv6 egress: pointing ::/0 at a TGW that can't route IPv6 black-holes the traffic and stalls clients without Happy Eyeballs (e.g. Python requests/urllib3) on the connection timeout. Left false (default), the VPC has no IPv6 default route, so IPv6 attempts fail immediately and clients use IPv4 with no delay. No effect when enable_transit_gateway is false." } variable "existing_vpc_id" { From f499603ef0f68bfdecb0c43a92a079b30d1cb3a2 Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Tue, 9 Jun 2026 16:46:38 +0500 Subject: [PATCH 10/12] Address review follow-up: egress-IP doc, negative-path tests, cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: note that toggling the mode changes the stack's public egress IP (NAT EIPs released on disable; egress via the TGW's NAT when enabled), so anything allowlisting Quilt's egress address must be updated or it breaks silently. - examples/main.tf: align the IPv6 comment with the corrected mechanism (off = no IPv6 default route, clients use IPv4) instead of "IPv4 fallback". - vpc tests: add negative-path runs — enabled-without-id (attachment precondition), malformed id (variable validation), and id-set-without-enable (no-op: no attachment/routes). - vpc main.tf: hoist the repeated `new_network_valid && enable_transit_gateway` count gate into a `transit_gateway_enabled` local; add a comment on why the attachment lands in intra subnets (ENI placement only). - quilt: align transit_gateway_id description with the vpc module (the new-VPC scoping lives on enable_transit_gateway). Co-Authored-By: Claude Opus 4.8 --- README.md | 7 ++- examples/main.tf | 2 +- modules/quilt/variables.tf | 2 +- modules/vpc/main.tf | 17 ++++-- modules/vpc/tests/validation.tftest.hcl | 73 +++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cd4b682..4a738bb 100644 --- a/README.md +++ b/README.md @@ -823,7 +823,12 @@ delay. **Reversibility:** removing `enable_transit_gateway` (or setting it `false`) restores the NAT gateways and IPv6 egress-only IGW. Toggling it on or off for an already-deployed VPC recreates/destroys NAT gateways and their Elastic IPs and -briefly interrupts egress, so do it in a maintenance window. +briefly interrupts egress, so do it in a maintenance window. Either direction +also **changes the stack's public egress IP** — disabling releases the NAT +Elastic IPs (AWS won't hand the same ones back), and enabling sends egress out +through the TGW's NAT instead — so anything that allowlists Quilt's egress +address (a license endpoint, a partner firewall, a SaaS IP allowlist) must be +updated, or it breaks silently. ### Profile You may wish to set a specific AWS profile before executing `terraform` diff --git a/examples/main.tf b/examples/main.tf index 37eb463..4fffab2 100644 --- a/examples/main.tf +++ b/examples/main.tf @@ -139,7 +139,7 @@ module "quilt" { # api_endpoint = "vpce-YOUR-VPC-ENDPOINT" # VPC endpoint (if internal = true) # enable_transit_gateway = true # Route private-subnet egress via a TGW instead of NAT (create_new_vpc = true only) # transit_gateway_id = "tgw-YOUR-TRANSIT-GATEWAY-ID" # Required when enable_transit_gateway = true; the TGW must route to the internet and back - # transit_gateway_ipv6_egress = true # Only if the TGW carries IPv6 egress (otherwise IPv6 is left on IPv4 fallback) + # transit_gateway_ipv6_egress = true # Only if the TGW carries IPv6 egress; off = no IPv6 default route (clients use IPv4) # CloudFormation notifications (optional) # stack_notification_arns = ["arn:aws:sns:YOUR-AWS-REGION:YOUR-ACCOUNT-ID:quilt-notifications"] diff --git a/modules/quilt/variables.tf b/modules/quilt/variables.tf index ba39e28..7c3e1c9 100644 --- a/modules/quilt/variables.tf +++ b/modules/quilt/variables.tf @@ -38,7 +38,7 @@ variable "enable_transit_gateway" { variable "transit_gateway_id" { type = string default = null - description = "Transit Gateway ID for private subnet egress when creating a new VPC. Required when enable_transit_gateway == true; may be a computed value (e.g. a TGW created in the same configuration)." + description = "Transit Gateway ID for private subnet egress. Required when enable_transit_gateway == true; may be a computed value (e.g. a TGW created in the same configuration)." validation { condition = var.transit_gateway_id == null || can(regex("^tgw-[0-9a-f]+$", var.transit_gateway_id)) error_message = "transit_gateway_id must be null or a valid Transit Gateway ID (e.g. tgw-0123456789abcdef0)." diff --git a/modules/vpc/main.tf b/modules/vpc/main.tf index 604b16a..c66d32f 100644 --- a/modules/vpc/main.tf +++ b/modules/vpc/main.tf @@ -33,6 +33,11 @@ locals { new_network_valid = alltrue(values(local.new_network_requires)) configuration_error = !local.existing_network_valid && !local.new_network_valid + # TGW egress is gated on the bool (not transit_gateway_id != null) so the + # resource counts stay known at plan time even when transit_gateway_id is a + # computed value (e.g. a TGW created in the same configuration). + transit_gateway_enabled = local.new_network_valid && var.enable_transit_gateway + azs = slice(data.aws_availability_zones.available.names, 0, 2) subnet_cidrs = [for k, v in local.azs : cidrsubnet(var.cidr, 1, k)] } @@ -78,11 +83,11 @@ module "vpc" { } resource "aws_ec2_transit_gateway_vpc_attachment" "egress" { - # Gate on the bool, not on transit_gateway_id != null: count must be known at - # plan time, and transit_gateway_id may be a computed value (e.g. a TGW - # created in the same configuration). - count = local.new_network_valid && var.enable_transit_gateway ? 1 : 0 + count = local.transit_gateway_enabled ? 1 : 0 + # Intra subnets only host the attachment ENIs (they have no internet route). + # The egress default routes go in the private route tables below — don't move + # this to private_subnets. subnet_ids = module.vpc.intra_subnets transit_gateway_id = var.transit_gateway_id vpc_id = module.vpc.vpc_id @@ -101,7 +106,7 @@ resource "aws_ec2_transit_gateway_vpc_attachment" "egress" { } resource "aws_route" "private_tgw_ipv4_egress" { - count = local.new_network_valid && var.enable_transit_gateway ? length(module.vpc.private_route_table_ids) : 0 + count = local.transit_gateway_enabled ? length(module.vpc.private_route_table_ids) : 0 route_table_id = module.vpc.private_route_table_ids[count.index] destination_cidr_block = "0.0.0.0/0" @@ -111,7 +116,7 @@ resource "aws_route" "private_tgw_ipv4_egress" { } resource "aws_route" "private_tgw_ipv6_egress" { - count = local.new_network_valid && var.enable_transit_gateway && var.transit_gateway_ipv6_egress ? length(module.vpc.private_route_table_ids) : 0 + count = local.transit_gateway_enabled && var.transit_gateway_ipv6_egress ? length(module.vpc.private_route_table_ids) : 0 route_table_id = module.vpc.private_route_table_ids[count.index] destination_ipv6_cidr_block = "::/0" diff --git a/modules/vpc/tests/validation.tftest.hcl b/modules/vpc/tests/validation.tftest.hcl index d826abf..918d1fe 100644 --- a/modules/vpc/tests/validation.tftest.hcl +++ b/modules/vpc/tests/validation.tftest.hcl @@ -293,3 +293,76 @@ run "existing_vpc_with_transit_gateway_is_rejected" { # ignore the request. expect_failures = [output.configuration_error] } + +run "transit_gateway_enabled_without_id_is_rejected" { + command = plan + + variables { + create_new_vpc = true + internal = false + enable_transit_gateway = true + transit_gateway_id = null + existing_vpc_id = null + existing_api_endpoint = null + existing_intra_subnets = null + existing_private_subnets = null + existing_public_subnets = null + existing_user_security_group = null + existing_user_subnets = null + } + + # enable_transit_gateway = true requires a transit_gateway_id; the attachment + # precondition must reject a null id. + expect_failures = [aws_ec2_transit_gateway_vpc_attachment.egress] +} + +run "transit_gateway_id_invalid_format_is_rejected" { + command = plan + + variables { + create_new_vpc = true + internal = false + enable_transit_gateway = true + transit_gateway_id = "not-a-tgw-id" + existing_vpc_id = null + existing_api_endpoint = null + existing_intra_subnets = null + existing_private_subnets = null + existing_public_subnets = null + existing_user_security_group = null + existing_user_subnets = null + } + + # A malformed transit_gateway_id must be rejected by the variable validation. + expect_failures = [var.transit_gateway_id] +} + +run "transit_gateway_id_without_enable_is_noop" { + command = plan + + variables { + create_new_vpc = true + internal = false + enable_transit_gateway = false + transit_gateway_id = "tgw-00000000000000000" + existing_vpc_id = null + existing_api_endpoint = null + existing_intra_subnets = null + existing_private_subnets = null + existing_public_subnets = null + existing_user_security_group = null + existing_user_subnets = null + } + + # transit_gateway_id is the value, enable_transit_gateway is the toggle: an id + # set without enabling the mode is a no-op — no attachment, no TGW routes. + assert { + condition = length(aws_ec2_transit_gateway_vpc_attachment.egress) == 0 + error_message = "No TGW attachment should be created when enable_transit_gateway is false" + } + + assert { + condition = length(aws_route.private_tgw_ipv4_egress) == 0 && length(aws_route.private_tgw_ipv6_egress) == 0 + error_message = "No TGW egress routes should be created when enable_transit_gateway is false" + } +} From 6ee976b87a92f1b3b27968c74ae923951de217e0 Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Tue, 9 Jun 2026 19:34:28 +0500 Subject: [PATCH 11/12] Apply review suggestions: link AWS RAM and Happy Eyeballs in TGW docs Co-Authored-By: Claude Opus 4.8 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4a738bb..7068893 100644 --- a/README.md +++ b/README.md @@ -799,7 +799,7 @@ create in the same Terraform configuration) — the toggle is the separate **You must provide the egress path.** Quilt owns only the VPC→TGW leg. Before apply, your TGW must: -- be reachable from the deployment account — share it via AWS RAM and accept +- be reachable from the deployment account — share it via [AWS Resource Access Manager](https://aws.amazon.com/ram/) and accept the VPC attachment (or enable auto-accept) if the TGW lives in another account; - have route tables that forward the VPC's egress out to the internet (e.g. via @@ -814,7 +814,7 @@ accordingly if more than one Quilt stack shares a TGW (the default is **IPv6** egress through the TGW is opt-in (`transit_gateway_ipv6_egress`, default `false`). The VPC is dual-stack, so set this `true` **only if your TGW actually carries IPv6 egress**: pointing `::/0` at a TGW that can't route IPv6 -black-holes those packets, and clients without Happy Eyeballs (e.g. Python's +black-holes those packets, and clients without [Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs) IPv6+IPv4 dual stack support (e.g. Python's `requests`/`urllib3`) then stall on the connection timeout before falling back to IPv4. Left `false`, the new VPC has no IPv6 default route, so an IPv6 attempt fails immediately (`ENETUNREACH`) and the client uses IPv4 with no From 7ae31a792d94729a0f0e87ca4fbca1897c379811 Mon Sep 17 00:00:00 2001 From: Sergey Fedoseev Date: Tue, 9 Jun 2026 19:36:52 +0500 Subject: [PATCH 12/12] Re-wrap TGW doc lines to the README's ~80-col prose width The applied review suggestions left two long unwrapped lines; re-flow them to match the surrounding wrap convention (wording unchanged). Co-Authored-By: Claude Opus 4.8 --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7068893..d40ca15 100644 --- a/README.md +++ b/README.md @@ -799,9 +799,9 @@ create in the same Terraform configuration) — the toggle is the separate **You must provide the egress path.** Quilt owns only the VPC→TGW leg. Before apply, your TGW must: -- be reachable from the deployment account — share it via [AWS Resource Access Manager](https://aws.amazon.com/ram/) and accept - the VPC attachment (or enable auto-accept) if the TGW lives in another - account; +- be reachable from the deployment account — share it via + [AWS Resource Access Manager](https://aws.amazon.com/ram/) and accept the VPC + attachment (or enable auto-accept) if the TGW lives in another account; - have route tables that forward the VPC's egress out to the internet (e.g. via a central egress VPC / NAT) **and** route return traffic back to the VPC's CIDR. @@ -814,8 +814,10 @@ accordingly if more than one Quilt stack shares a TGW (the default is **IPv6** egress through the TGW is opt-in (`transit_gateway_ipv6_egress`, default `false`). The VPC is dual-stack, so set this `true` **only if your TGW actually carries IPv6 egress**: pointing `::/0` at a TGW that can't route IPv6 -black-holes those packets, and clients without [Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs) IPv6+IPv4 dual stack support (e.g. Python's -`requests`/`urllib3`) then stall on the connection timeout before falling back +black-holes those packets, and clients without +[Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs) IPv6+IPv4 dual +stack support (e.g. Python's `requests`/`urllib3`) then stall on the connection +timeout before falling back to IPv4. Left `false`, the new VPC has no IPv6 default route, so an IPv6 attempt fails immediately (`ENETUNREACH`) and the client uses IPv4 with no delay.