Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions aws-hub-egress/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
output "transit_gateway_id" {
value = aws_ec2_transit_gateway.hub.id
description = "Pass this to the Redpanda BYOC module as transit_gateway_id."
}

output "transit_gateway_arn" {
value = aws_ec2_transit_gateway.hub.arn
description = "ARN of the Transit Gateway."
}

output "ram_resource_share_arn" {
value = local.share_with_redpanda ? aws_ram_resource_share.tgw[0].arn : null
description = "ARN of the RAM share. Redpanda must accept this invitation before attaching. Null when hub and spoke share the same account."
}

output "hub_vpc_id" {
value = aws_vpc.hub.id
description = "ID of the hub/egress VPC."
}

output "nat_gateway_public_ip" {
value = aws_eip.nat.public_ip
description = "Public IP of the NAT Gateway — all spoke egress traffic will appear from this IP."
}
12 changes: 12 additions & 0 deletions aws-hub-egress/provider.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}

provider "aws" {
region = var.region
}
46 changes: 46 additions & 0 deletions aws-hub-egress/routing.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Public subnet route table: internet exit via IGW + return routes to spokes via TGW.
# NAT Gateway reply traffic is forwarded back to spokes from here.
resource "aws_route_table" "public" {
vpc_id = aws_vpc.hub.id
tags = merge(var.default_tags, { Name = "${var.common_prefix}-public-rt" })
}

resource "aws_route" "public_internet" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.hub.id
}

# Return routes: one per spoke CIDR so NAT Gateway replies reach the correct spoke.
resource "aws_route" "public_to_spoke" {
count = length(var.spoke_cidrs)
route_table_id = aws_route_table.public.id
destination_cidr_block = var.spoke_cidrs[count.index]
transit_gateway_id = aws_ec2_transit_gateway.hub.id
depends_on = [aws_ec2_transit_gateway_vpc_attachment.hub]
}

resource "aws_route_table_association" "public" {
count = length(var.public_subnet_cidrs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}

# Private subnet route table: all traffic exits via NAT Gateway.
# TGW attachment lives in these subnets; ingress from spokes is routed to NAT here.
resource "aws_route_table" "private" {
vpc_id = aws_vpc.hub.id
tags = merge(var.default_tags, { Name = "${var.common_prefix}-private-rt" })
}

resource "aws_route" "private_internet" {
route_table_id = aws_route_table.private.id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.hub.id
}

resource "aws_route_table_association" "private" {
count = length(var.private_subnet_cidrs)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
55 changes: 55 additions & 0 deletions aws-hub-egress/tgw.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
resource "aws_ec2_transit_gateway" "hub" {
description = "Hub TGW for centralized egress"
auto_accept_shared_attachments = "enable"
default_route_table_association = "enable"
default_route_table_propagation = "enable"

tags = merge(var.default_tags, { Name = "${var.common_prefix}-tgw" })
}

# Default route in the TGW route table → hub VPC attachment.
# Spoke VPCs that attach will have internet-bound traffic forwarded here.
resource "aws_ec2_transit_gateway_route" "default_egress" {
destination_cidr_block = "0.0.0.0/0"
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.hub.id
transit_gateway_route_table_id = aws_ec2_transit_gateway.hub.association_default_route_table_id
}

# Attach the hub VPC to the TGW using the private subnets.
resource "aws_ec2_transit_gateway_vpc_attachment" "hub" {
transit_gateway_id = aws_ec2_transit_gateway.hub.id
vpc_id = aws_vpc.hub.id
subnet_ids = aws_subnet.private[*].id

transit_gateway_default_route_table_association = true
transit_gateway_default_route_table_propagation = true

tags = merge(var.default_tags, { Name = "${var.common_prefix}-tgw-attachment" })
}

# Share the TGW with Redpanda's AWS account via Resource Access Manager.
# Skipped when Redpanda's account is the same as the owning account (RAM rejects self-sharing).
data "aws_caller_identity" "current" {}

locals {
share_with_redpanda = var.redpanda_aws_account_id != data.aws_caller_identity.current.account_id
}

resource "aws_ram_resource_share" "tgw" {
count = local.share_with_redpanda ? 1 : 0
name = "${var.common_prefix}-tgw-share"
allow_external_principals = true
tags = var.default_tags
}

resource "aws_ram_resource_association" "tgw" {
count = local.share_with_redpanda ? 1 : 0
resource_arn = aws_ec2_transit_gateway.hub.arn
resource_share_arn = aws_ram_resource_share.tgw[0].arn
}

resource "aws_ram_principal_association" "redpanda" {
count = local.share_with_redpanda ? 1 : 0
principal = var.redpanda_aws_account_id
resource_share_arn = aws_ram_resource_share.tgw[0].arn
}
64 changes: 64 additions & 0 deletions aws-hub-egress/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
variable "region" {
type = string
default = "us-east-2"
description = "AWS region where the hub VPC and TGW will be created."
}

variable "common_prefix" {
type = string
default = "hub"
description = "Prefix applied to all resource names."
}

variable "vpc_cidr" {
type = string
default = "100.64.0.0/16"
description = <<-HELP
CIDR for the hub/egress VPC.
MUST NOT overlap with any spoke VPC CIDRs — TGW routing breaks silently when CIDRs
collide because the route table can only hold one entry per prefix.
100.64.0.0/16 (CGNAT range) is a safe default: it is not routable on the public
internet and is unlikely to be used by spoke VPCs.
HELP
}

variable "public_subnet_cidrs" {
type = list(string)
default = ["100.64.0.0/24", "100.64.1.0/24"]
description = "CIDRs for public subnets (one per AZ). NAT Gateway lives here. Must be within vpc_cidr."
}

variable "private_subnet_cidrs" {
type = list(string)
default = ["100.64.2.0/24", "100.64.3.0/24"]
description = "CIDRs for private subnets (one per AZ). TGW attachment lives here. Must be within vpc_cidr."
}

variable "spoke_cidrs" {
type = list(string)
default = ["10.0.0.0/16"]
description = <<-HELP
CIDRs of all spoke VPCs that will attach to this TGW.
Used to install return routes in the hub's public subnet so NAT Gateway replies
are forwarded back to the correct spoke via TGW.
Must match the actual VPC CIDRs used by the Redpanda BYOC clusters — a mismatch
here (e.g. 172.16.0.0/16 when the cluster uses 10.0.0.0/16) is the most common
cause of one-way connectivity (egress works, replies are dropped).
Must not overlap with vpc_cidr.
HELP
}

variable "redpanda_aws_account_id" {
type = string
default = "472797112831"
description = <<-HELP
Redpanda's AWS account ID. The TGW will be shared with this account via RAM so
Redpanda can attach the BYOC spoke VPC to it.
HELP
}

variable "default_tags" {
type = map(string)
default = {}
description = "Tags applied to all resources."
}
49 changes: 49 additions & 0 deletions aws-hub-egress/vpc.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
data "aws_availability_zones" "available" {
state = "available"
filter {
name = "opt-in-status"
values = ["opt-in-not-required"]
}
}

resource "aws_vpc" "hub" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(var.default_tags, { Name = "${var.common_prefix}-vpc" })
}

resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.hub.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = false
tags = merge(var.default_tags, { Name = "${var.common_prefix}-public-${count.index}" })
}

resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.hub.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = merge(var.default_tags, { Name = "${var.common_prefix}-private-${count.index}" })
}

resource "aws_internet_gateway" "hub" {
vpc_id = aws_vpc.hub.id
tags = merge(var.default_tags, { Name = "${var.common_prefix}-igw" })
}

resource "aws_eip" "nat" {
domain = "vpc"
tags = merge(var.default_tags, { Name = "${var.common_prefix}-nat-eip" })
}

# Single NAT Gateway in the first public subnet is sufficient for egress.
resource "aws_nat_gateway" "hub" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
depends_on = [aws_internet_gateway.hub]
tags = merge(var.default_tags, { Name = "${var.common_prefix}-nat" })
}