main.tf
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# VPC & CIDR Associations =====================================================
# Creates the VPC with a primary CIDR and associates any secondary CIDRs.
# All subnets depend on this block being complete before provisioning.

resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_hostnames = var.enable_dns_hostnames
  enable_dns_support   = var.enable_dns_support

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.region_abbr}-vpc"
    resource-type = "vpc"
  })
}

resource "aws_vpc_ipv4_cidr_block_association" "this" {
  for_each = toset(var.secondary_cidr_blocks)

  vpc_id     = aws_vpc.this.id
  cidr_block = each.value
}

# Subnets ======================================================================
# 3 tiers: public (IGW-routed), private (NAT-routed), isolated (no egress)
# All tiers depend on CIDR associations being complete before provisioning.

resource "aws_subnet" "public" {
  for_each = var.public_subnets

  vpc_id                  = aws_vpc.this.id
  cidr_block              = each.value.cidr_block
  availability_zone       = each.value.availability_zone
  map_public_ip_on_launch = each.value.map_public_ip

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-subnet-public-${each.key}"
    resource-type = "subnet"
    reachability  = "public"
  })

  depends_on = [aws_vpc_ipv4_cidr_block_association.this]
}

resource "aws_subnet" "private" {
  for_each = var.private_subnets

  vpc_id            = aws_vpc.this.id
  cidr_block        = each.value.cidr_block
  availability_zone = each.value.availability_zone

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-subnet-private-${each.key}"
    resource-type = "subnet"
    reachability  = "private"
  })

  depends_on = [aws_vpc_ipv4_cidr_block_association.this]
}

resource "aws_subnet" "isolated" {
  for_each = var.isolated_subnets

  vpc_id            = aws_vpc.this.id
  cidr_block        = each.value.cidr_block
  availability_zone = each.value.availability_zone

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-subnet-isolated-${each.key}"
    resource-type = "subnet"
    reachability  = "isolated"
  })

  depends_on = [aws_vpc_ipv4_cidr_block_association.this]
}

# Internet Gateway ===========================================================
# Created only when public subnets are defined, skipped otherwise.
# The IGW itself needs no configuration beyond attaching to the VPC.

resource "aws_internet_gateway" "this" {
  for_each = length(var.public_subnets) > 0 ? { igw = true } : {}

  vpc_id = aws_vpc.this.id

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.region_abbr}-igw"
    resource-type = "internet-gateway"
  })
}

# NAT Gateways ===============================================================
# Skipped entirely when enable_nat_gateway is false.
# Each NAT gateway requires a static public IP (EIP) and sits in a public subnet.
# Supports single NAT (cost-saving) or one NAT per AZ (high availability).

resource "aws_eip" "nat" {
  for_each = local.nat_gateway_keys

  domain = "vpc"

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-nat-eip-${each.key}"
    resource-type = "eip"
  })

  depends_on = [aws_internet_gateway.this]
}

resource "aws_nat_gateway" "this" {
  for_each = local.nat_gateway_keys

  allocation_id = aws_eip.nat[each.key].id
  subnet_id = var.single_nat_gateway ? (
    values(aws_subnet.public)[0].id
  ) : aws_subnet.public[each.key].id

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-nat-${each.key}"
    resource-type = "nat-gateway"
  })

  depends_on = [aws_internet_gateway.this]
}

# Route Tables ===============================================================
# Public subnets share a single RT with a default route to the IGW.
# Private and isolated subnets each get a dedicated RT for per-subnet flexibility.
# Default egress for private subnets is NAT when enable_nat_gateway is true.

# --- Public -----------------------------------------------------------------

resource "aws_route_table" "public" {
  for_each = length(var.public_subnets) > 0 ? { public = true } : {}

  vpc_id = aws_vpc.this.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this["igw"].id
  }

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.region_abbr}-rt-public"
    resource-type = "route-table"
    reachability  = "public"
  })
}

resource "aws_route_table_association" "public" {
  for_each = var.public_subnets

  subnet_id      = aws_subnet.public[each.key].id
  route_table_id = aws_route_table.public["public"].id
}

# --- Private ----------------------------------------------------------------

resource "aws_route_table" "private" {
  for_each = var.private_subnets

  vpc_id = aws_vpc.this.id

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-rt-private-${each.key}"
    resource-type = "route-table"
    reachability  = "private"
  })
}

resource "aws_route" "private_nat" {
  for_each = var.enable_nat_gateway ? var.private_subnets : {}

  route_table_id         = aws_route_table.private[each.key].id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id = var.single_nat_gateway ? (
    aws_nat_gateway.this["single"].id
    ) : (
    [
      for k, v in aws_nat_gateway.this : v.id
      if aws_subnet.public[k].availability_zone == aws_subnet.private[each.key].availability_zone
    ][0]
  )
}

resource "aws_route_table_association" "private" {
  for_each = var.private_subnets

  subnet_id      = aws_subnet.private[each.key].id
  route_table_id = aws_route_table.private[each.key].id
}

# --- Isolated ---------------------------------------------------------------

resource "aws_route_table" "isolated" {
  for_each = var.isolated_subnets

  vpc_id = aws_vpc.this.id

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-rt-isolated-${each.key}"
    resource-type = "route-table"
    reachability  = "isolated"
  })
}

resource "aws_route_table_association" "isolated" {
  for_each = var.isolated_subnets

  subnet_id      = aws_subnet.isolated[each.key].id
  route_table_id = aws_route_table.isolated[each.key].id
}

# TGW VPC Attachment =========================================================
# Created only when transit_gateway_id is set.
# Attachment subnets are derived from tgw_subnet_keys within isolated_subnets.
# RT association and propagation are created when transit_gateway_route_table_id is set,
# otherwise the TGW default route table is used.

resource "aws_ec2_transit_gateway_vpc_attachment" "this" {
  count = var.transit_gateway_id != null ? 1 : 0

  transit_gateway_id = var.transit_gateway_id
  vpc_id             = aws_vpc.this.id
  subnet_ids = [
    for k, v in aws_subnet.isolated : v.id
    if contains(var.tgw_subnet_keys, k)
  ]

  dns_support  = "enable"
  ipv6_support = "disable"

  transit_gateway_default_route_table_association = var.tgw_default_route_table_association
  transit_gateway_default_route_table_propagation = var.tgw_default_route_table_propagation

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.region_abbr}-tgw-attach"
    resource-type = "tgw-attachment"
  })
}

resource "aws_ec2_transit_gateway_route_table_association" "this" {
  count = var.transit_gateway_id != null && var.transit_gateway_route_table_id != null ? 1 : 0

  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.this[0].id
  transit_gateway_route_table_id = var.transit_gateway_route_table_id
}

resource "aws_ec2_transit_gateway_route_table_propagation" "this" {
  count = var.transit_gateway_id != null && var.transit_gateway_route_table_id != null ? 1 : 0

  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.this[0].id
  transit_gateway_route_table_id = var.transit_gateway_route_table_id
}

# TGW Routes =================================================================
# Default routes to TGW for all isolated subnets except attachment subnets.
# depends_on ensures the attachment is fully available before routes are created,
# avoiding the InvalidTransitGatewayID.NotFound error on fresh TGW deployments.

resource "aws_route" "isolated_tgw" {
  for_each = var.transit_gateway_id != null ? {
    for k, v in var.isolated_subnets : k => v
    if !contains(var.tgw_subnet_keys, k)
  } : {}

  route_table_id         = aws_route_table.isolated[each.key].id
  destination_cidr_block = "0.0.0.0/0"
  transit_gateway_id     = var.transit_gateway_id

  depends_on = [aws_ec2_transit_gateway_vpc_attachment.this]
}

# VPC Flow Logs ==============================================================
# Skipped entirely when enable_flow_logs is false.
# Supports both CloudWatch and S3 destinations, auto-detected from the ARN.
# IAM role and inline policy are created for CloudWatch destinations only.
# The S3 bucket and CloudWatch log group are expected to exist before this module runs.

resource "aws_iam_role" "flow_log" {
  for_each = var.enable_flow_logs ? { role = true } : {}

  name                 = "${var.name_prefix}-${local.region_abbr}-flow-log-role"
  permissions_boundary = var.flow_log_role_permissions_boundary_arn

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Action    = "sts:AssumeRole"
      Principal = { Service = "vpc-flow-logs.amazonaws.com" }
    }]
  })

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.region_abbr}-flow-log-role"
    resource-type = "iam-role"
  })

  lifecycle {
    precondition {
      condition     = length("${var.name_prefix}-${local.region_abbr}-flow-log-role") <= 64
      error_message = "Assembled IAM role name exceeds AWS's 64-character limit"
    }
  }
}

resource "aws_iam_role_policy" "flow_log" {
  for_each = var.enable_flow_logs ? { role = true } : {}

  name = "${var.name_prefix}-${local.region_abbr}-flow-log-policy"
  role = aws_iam_role.flow_log["role"].id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents",
        "logs:DescribeLogGroups",
        "logs:DescribeLogStreams"
      ]
      Resource = "*"
    }]
  })
}

resource "aws_flow_log" "this" {
  for_each = var.enable_flow_logs ? { flow_log = true } : {}

  vpc_id                   = aws_vpc.this.id
  traffic_type             = var.flow_log_traffic_type
  log_destination_type     = local.flow_log_destination_type
  log_destination          = var.flow_log_destination_arn
  iam_role_arn             = local.flow_log_destination_type == "cloud-watch-logs" ? aws_iam_role.flow_log["role"].arn : null
  max_aggregation_interval = 60

  tags = merge(var.tags, local.module_tags, {
    Name          = "${var.name_prefix}-${local.region_abbr}-flow-log"
    resource-type = "flow-log"
  })
}