From 855f0b4715b2cc0baa3e62adcb5b5d5480e6d588 Mon Sep 17 00:00:00 2001 From: piravinth Date: Tue, 2 Jun 2026 14:11:58 +0100 Subject: [PATCH 01/13] update existing VPC module to wrap upstream module --- infrastructure/modules/vpc/context.tf | 374 ++++++++++++++++++++++++ infrastructure/modules/vpc/locals.tf | 20 ++ infrastructure/modules/vpc/main.tf | 263 +++++------------ infrastructure/modules/vpc/outputs.tf | 136 ++++++++- infrastructure/modules/vpc/variables.tf | 134 ++++++++- infrastructure/modules/vpc/versions.tf | 2 +- 6 files changed, 708 insertions(+), 221 deletions(-) create mode 100644 infrastructure/modules/vpc/context.tf create mode 100644 infrastructure/modules/vpc/locals.tf diff --git a/infrastructure/modules/vpc/context.tf b/infrastructure/modules/vpc/context.tf new file mode 100644 index 00000000..cef74175 --- /dev/null +++ b/infrastructure/modules/vpc/context.tf @@ -0,0 +1,374 @@ +# +# ONLY EDIT THIS FILE IN github.com/NHSDigital/screening-terraform-modules-aws/infrastructure/modules/tags +# All other instances of this file should be a copy of that one +# +# +# Copy this file from https://github.com/NHSDigital/screening-terraform-modules-aws/blob/master/infrastructure/modules/tags/exports/context.tf +# and then place it in your Terraform module to automatically get +# tag module standard configuration inputs suitable for passing +# to other modules. +# +# curl -sL https://raw.githubusercontent.com/NHSDigital/screening-terraform-modules-aws/master/infrastructure/modules/tags/exports/context.tf -o context.tf +# +# Modules should access the whole context as `module.this.context` +# to get the input variables with nulls for defaults, +# for example `context = module.this.context`, +# and access individual variables as `module.this.`, +# with final values filled in. +# +# For example, when using defaults, `module.this.context.delimiter` +# will be null, and `module.this.delimiter` will be `-` (hyphen). +# + +module "this" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags?ref=v2.5.0" + + service = var.service + project = var.project + region = var.region + environment = var.environment + stack = var.stack + workspace = var.workspace + name = var.name + delimiter = var.delimiter + attributes = var.attributes + tags = var.tags + additional_tag_map = var.additional_tag_map + label_order = var.label_order + regex_replace_chars = var.regex_replace_chars + id_length_limit = var.id_length_limit + label_key_case = var.label_key_case + label_value_case = var.label_value_case + terraform_source = coalesce(var.terraform_source, path.module) + descriptor_formats = var.descriptor_formats + labels_as_tags = var.labels_as_tags + + context = var.context +} + +# Copy contents of screening-terraform-modules-aws/tags/variables.tf here +# tflint-ignore: terraform_unused_declarations +variable "aws_region" { + type = string + description = "The AWS region" + default = "eu-west-2" + validation { + condition = contains(["eu-west-1", "eu-west-2", "us-east-1"], var.aws_region) + error_message = "AWS Region must be one of eu-west-1, eu-west-2, us-east-1" + } +} + +variable "context" { + type = any + default = { + enabled = true + service = null + project = null + region = null + environment = null + stack = null + workspace = null + name = null + delimiter = null + attributes = [] + tags = {} + additional_tag_map = {} + regex_replace_chars = null + label_order = [] + id_length_limit = null + label_key_case = null + label_value_case = null + terraform_source = null + descriptor_formats = {} + # Note: we have to use [] instead of null for unset lists due to + # https://github.com/hashicorp/terraform/issues/28137 + # which was not fixed until Terraform 1.0.0, + # but we want the default to be all the labels in `label_order` + # and we want users to be able to prevent all tag generation + # by setting `labels_as_tags` to `[]`, so we need + # a different sentinel to indicate "default" + labels_as_tags = ["unset"] + } + description = <<-EOT + Single object for setting entire context at once. + See description of individual variables for details. + Leave string and numeric variables as `null` to use default value. + Individual variable settings (non-null) override settings in context object, + except for attributes, tags, and additional_tag_map, which are merged. + EOT + + validation { + condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`." + } + + validation { + condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "terraform_source" { + type = string + default = null + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." +} + +variable "enabled" { + type = bool + default = null + description = "Set to false to prevent the module from creating any resources" +} + +variable "service" { + type = string + default = null + description = "ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique" +} + +variable "region" { + type = string + default = null + description = "ID element _(Rarely used, not included by default)_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region" +} + +variable "project" { + type = string + default = null + description = "ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api`" +} +variable "stack" { + type = string + default = null + description = "ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks`" +} +variable "workspace" { + type = string + default = null + description = "ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces" +} +variable "environment" { + type = string + default = null + description = "ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat'" +} + +variable "name" { + type = string + default = null + description = <<-EOT + ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'. + This is the only ID element not also included as a `tag`. + The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. + EOT +} + +variable "delimiter" { + type = string + default = null + description = <<-EOT + Delimiter to be used between ID elements. + Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. + EOT +} + +variable "attributes" { + type = list(string) + default = [] + description = <<-EOT + ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`, + in the order they appear in the list. New attributes are appended to the + end of the list. The elements of the list are joined by the `delimiter` + and treated as a single ID element. + EOT +} + +variable "labels_as_tags" { + type = set(string) + default = ["default"] + description = <<-EOT + Set of labels (ID elements) to include as tags in the `tags` output. + Default is to include all labels. + Tags with empty values will not be included in the `tags` output. + Set to `[]` to suppress all generated tags. + **Notes:** + The value of the `name` tag, if included, will be the `id`, not the `name`. + Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be + changed in later chained modules. Attempts to change it will be silently ignored. + EOT +} + +variable "tags" { + type = map(string) + default = {} + description = <<-EOT + Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`). + Neither the tag keys nor the tag values will be modified by this module. + EOT +} + +variable "additional_tag_map" { + type = map(string) + default = {} + description = <<-EOT + Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`. + This is for some rare cases where resources want additional configuration of tags + and therefore take a list of maps with tag key, value, and additional configuration. + EOT +} + +variable "label_order" { + type = list(string) + default = null + description = <<-EOT + The order in which the labels (ID elements) appear in the `id`. + Defaults to ["namespace", "environment", "stage", "name", "attributes"]. + You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. + EOT +} + +variable "regex_replace_chars" { + type = string + default = null + description = <<-EOT + Terraform regular expression (regex) string. + Characters matching the regex will be removed from the ID elements. + If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. + EOT +} + +variable "id_length_limit" { + type = number + default = null + description = <<-EOT + Limit `id` to this many characters (minimum 6). + Set to `0` for unlimited length. + Set to `null` for keep the existing setting, which defaults to `0`. + Does not affect `id_full`. + EOT + validation { + condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 + error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." + } +} + +variable "label_key_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of the `tags` keys (label names) for tags generated by this module. + Does not affect keys of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper`. + Default value: `title`. + EOT + + validation { + condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) + error_message = "Allowed values: `lower`, `title`, `upper`." + } +} + +variable "label_value_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of ID elements (labels) as included in `id`, + set as tag values, and output by this module individually. + Does not affect values of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper` and `none` (no transformation). + Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs. + Default value: `lower`. + EOT + + validation { + condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "descriptor_formats" { + type = any + default = {} + description = <<-EOT + Describe additional descriptors to be output in the `descriptors` output map. + Map of maps. Keys are names of descriptors. Values are maps of the form + `{ + format = string + labels = list(string) + }` + (Type is `any` so the map values can later be enhanced to provide additional options.) + `format` is a Terraform format string to be passed to the `format()` function. + `labels` is a list of labels, in order, to pass to `format()` function. + Label values will be normalized before being passed to `format()` so they will be + identical to how they appear in `id`. + Default is `{}` (`descriptors` output will be empty). + EOT +} + +variable "owner" { + type = string + description = "The name and or NHS.net email address of the service owner" + default = "None" +} + +variable "tag_version" { + type = string + description = "Used to identify the tagging version in use" + default = "1.0" +} + +variable "data_classification" { + type = string + description = "Used to identify the data classification of the resource, e.g 1-5" + default = "n/a" + validation { + condition = contains(["n/a", "1", "2", "3", "4", "5"], var.data_classification) + error_message = "Data Classification must be \"n/a\" or between 1-5" + } +} + +variable "data_type" { + type = string + description = "The tag data_type" + default = "None" + validation { + condition = contains(["None", "PCD", "PID", "Anonymised", "UserAccount", "Audit"], var.data_type) + error_message = "Data Type must be one of None, PCD, PID, Anonymised, UserAccount, Audit" + } +} + + +variable "public_facing" { + type = bool + description = "Whether this resource is public facing" + default = false +} + +variable "service_category" { + type = string + description = "The tag service_category" + default = "n/a" + validation { + condition = contains(["n/a", "Bronze", "Silver", "Gold", "Platinum"], var.service_category) + error_message = "The Service Category must be one of n/a, Bronze, Silver, Gold, Platinum" + } +} +variable "on_off_pattern" { + type = string + description = "Used to turn resources on and off based on a time pattern" + default = "n/a" +} + +variable "application_role" { + type = string + description = "The role the application is performing" + default = "General" +} + +variable "tool" { + type = string + description = "The tool used to deploy the resource" + default = "Terraform" +} + +#### End of copy of screening-terraform-modules-aws/tags/variables.tf diff --git a/infrastructure/modules/vpc/locals.tf b/infrastructure/modules/vpc/locals.tf new file mode 100644 index 00000000..34b61854 --- /dev/null +++ b/infrastructure/modules/vpc/locals.tf @@ -0,0 +1,20 @@ +data "aws_availability_zones" "available" { + state = "available" +} + +locals { + azs = data.aws_availability_zones.available.names + az_count = length(local.azs) + + # Subnet CIDR allocation from the VPC CIDR (assumes /16) + + auto_firewall_subnets = [for i in range(local.az_count) : cidrsubnet(cidrsubnet(var.vpc_cidr, 8, 0), 4, i)] + auto_public_subnets = [for i in range(local.az_count) : cidrsubnet(var.vpc_cidr, 8, 16 + i)] + auto_private_subnets = [for i in range(local.az_count) : cidrsubnet(var.vpc_cidr, 7, 16 + i)] + auto_isolated_subnets = [for i in range(local.az_count) : cidrsubnet(var.vpc_cidr, 7, 24 + i)] + + firewall_subnets = length(var.firewall_subnets) > 0 ? var.firewall_subnets : local.auto_firewall_subnets + public_subnets = length(var.public_subnets) > 0 ? var.public_subnets : local.auto_public_subnets + private_subnets = length(var.private_subnets) > 0 ? var.private_subnets : local.auto_private_subnets + isolated_subnets = length(var.isolated_subnets) > 0 ? var.isolated_subnets : local.auto_isolated_subnets +} diff --git a/infrastructure/modules/vpc/main.tf b/infrastructure/modules/vpc/main.tf index 588423c8..41e1186f 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -1,220 +1,91 @@ -# For eks to work with fargate we need to setup both public and private subnets -# The fargate nodes will deploy into the private subnets, any outbound traffic -# Will pass from the private subnets > Nat gateway > Public Subnets > Internet Gateway -# This is a complicated setup but is required to allow external acces to do things like -# pull container images - -# Create the VPC -resource "aws_vpc" "vpc" { - cidr_block = "${var.vpc_cidr_prefix}.0.0/16" - instance_tenancy = "default" - enable_dns_support = true - enable_dns_hostnames = true - tags = { - Name = var.name_prefix - } -} +################################################################ +# VPC Module +# +# Screening wrapper +# `terraform-aws-modules/vpc/aws` module +# /28 firewall – Network Firewall endpoints +# /24 public – public-facing resources, NAT gateways +# /23 private – private workloads with internet via NAT +# /23 isolated – fully isolated, no internet route +# +# Naming and tagging are derived from context.tf via module.this. +################################################################ -# attach public subnets to vpc -resource "aws_subnet" "public_subnet_a" { - cidr_block = "${var.vpc_cidr_prefix}.0.0/24" - availability_zone = "eu-west-2a" - vpc_id = aws_vpc.vpc.id - map_public_ip_on_launch = true - tags = { - "Name" = "${var.name_prefix}-public-a" - "Type" = "public" - } -} +module "vpc" { + source = "terraform-aws-modules/vpc/aws" + version = "6.6.1" -resource "aws_subnet" "public_subnet_b" { - cidr_block = "${var.vpc_cidr_prefix}.1.0/24" - availability_zone = "eu-west-2b" - vpc_id = aws_vpc.vpc.id - map_public_ip_on_launch = true - tags = { - "Name" = "${var.name_prefix}-public-b" - "Type" = "public" - } -} + create_vpc = module.this.enabled -resource "aws_subnet" "public_subnet_c" { - cidr_block = "${var.vpc_cidr_prefix}.4.0/24" - availability_zone = "eu-west-2c" - vpc_id = aws_vpc.vpc.id - map_public_ip_on_launch = true - tags = { - "Name" = "${var.name_prefix}-public-c" - "Type" = "public" - } -} + name = module.this.id + cidr = var.vpc_cidr -# attach private subnets to vpc -resource "aws_subnet" "private_subnet_a" { - cidr_block = "${var.vpc_cidr_prefix}.2.0/24" - availability_zone = "eu-west-2a" - vpc_id = aws_vpc.vpc.id - map_public_ip_on_launch = false - tags = { - "Name" = "${var.name_prefix}-private-a" - "Type" = "private" - } -} + azs = local.azs + public_subnets = local.public_subnets + private_subnets = local.private_subnets + intra_subnets = local.isolated_subnets -resource "aws_subnet" "private_subnet_b" { - cidr_block = "${var.vpc_cidr_prefix}.3.0/24" - availability_zone = "eu-west-2b" - vpc_id = aws_vpc.vpc.id - map_public_ip_on_launch = false - tags = { - "Name" = "${var.name_prefix}-private-b" - "Type" = "private" - } -} + # NAT gateway configuration + enable_nat_gateway = true + single_nat_gateway = var.single_nat_gateway + one_nat_gateway_per_az = !var.single_nat_gateway -resource "aws_subnet" "private_subnet_c" { - cidr_block = "${var.vpc_cidr_prefix}.5.0/24" - availability_zone = "eu-west-2c" - vpc_id = aws_vpc.vpc.id - map_public_ip_on_launch = false - tags = { - "Name" = "${var.name_prefix}-private-c" - "Type" = "private" - } -} + # DNS + enable_dns_hostnames = var.enable_dns_hostnames + enable_dns_support = var.enable_dns_support -# Create the internet gateway, -# this will allow traffic from the public subnets out to the internet -resource "aws_internet_gateway" "igw" { - vpc_id = aws_vpc.vpc.id - tags = { - Name = var.name_prefix - } -} + # Public subnets + map_public_ip_on_launch = var.map_public_ip_on_launch -# create a route table so traffic in the public subnets -# can breakout to the internet using the internet gateway -resource "aws_route_table" "public_rt" { - vpc_id = aws_vpc.vpc.id - - route { - cidr_block = "0.0.0.0/0" - gateway_id = aws_internet_gateway.igw.id - } - tags = { - Name = var.name_prefix - } -} + # Security defaults + manage_default_security_group = var.manage_default_security_group + default_security_group_ingress = [] + default_security_group_egress = [] -# Create the nat gateways that allow traffic from the private subnets -# To break out into the public subnets -resource "aws_nat_gateway" "nat_gw_a" { - allocation_id = aws_eip.eip_a.id - subnet_id = aws_subnet.public_subnet_a.id - tags = { - Name = var.name_prefix - } -} + manage_default_network_acl = var.manage_default_network_acl + manage_default_route_table = true -resource "aws_eip" "eip_a" { - tags = { - Name = var.name_prefix - } -} + # Subnet tags + public_subnet_tags = var.public_subnet_tags + private_subnet_tags = var.private_subnet_tags + intra_subnet_tags = var.isolated_subnet_tags -resource "aws_nat_gateway" "nat_gw_b" { - allocation_id = aws_eip.eip_b.id - subnet_id = aws_subnet.public_subnet_b.id - tags = { - Name = var.name_prefix - } + tags = module.this.tags } -resource "aws_eip" "eip_b" { - tags = { - Name = var.name_prefix - } -} +################################################################ +# Firewall subnets +# +# Created as standalone resources because the upstream module +# does not have a dedicated firewall subnet tier. +################################################################ -resource "aws_nat_gateway" "nat_gw_c" { - allocation_id = aws_eip.eip_c.id - subnet_id = aws_subnet.public_subnet_c.id - tags = { - Name = var.name_prefix - } -} +resource "aws_subnet" "firewall" { + count = module.this.enabled ? local.az_count : 0 -resource "aws_eip" "eip_c" { - tags = { - Name = var.name_prefix - } -} - - -# create a route table so traffic in the private subnets -# can use the nat gateways -resource "aws_route_table" "private_rt_a" { - vpc_id = aws_vpc.vpc.id - - route { - cidr_block = "0.0.0.0/0" - nat_gateway_id = aws_nat_gateway.nat_gw_a.id - } - tags = { - Name = var.name_prefix - } -} - -resource "aws_route_table" "private_rt_b" { - vpc_id = aws_vpc.vpc.id + vpc_id = module.vpc.vpc_id + cidr_block = local.firewall_subnets[count.index] + availability_zone = local.azs[count.index] - route { - cidr_block = "0.0.0.0/0" - nat_gateway_id = aws_nat_gateway.nat_gw_b.id - } - tags = { - Name = var.name_prefix - } -} -resource "aws_route_table" "private_rt_c" { - vpc_id = aws_vpc.vpc.id - - route { - cidr_block = "0.0.0.0/0" - nat_gateway_id = aws_nat_gateway.nat_gw_c.id - } - tags = { - Name = var.name_prefix - } -} - -# associate the route tables with the subnets -resource "aws_route_table_association" "private_rta_a" { - subnet_id = aws_subnet.private_subnet_a.id - route_table_id = aws_route_table.private_rt_a.id + tags = merge(module.this.tags, var.firewall_subnet_tags, { + Name = "${module.this.id}-firewall-${local.azs[count.index]}" + Type = "firewall" + }) } -resource "aws_route_table_association" "private_rta_b" { - subnet_id = aws_subnet.private_subnet_b.id - route_table_id = aws_route_table.private_rt_b.id -} +resource "aws_route_table" "firewall" { + count = module.this.enabled ? local.az_count : 0 -resource "aws_route_table_association" "private_rta_c" { - subnet_id = aws_subnet.private_subnet_c.id - route_table_id = aws_route_table.private_rt_c.id -} + vpc_id = module.vpc.vpc_id -resource "aws_route_table_association" "public_rta_a" { - subnet_id = aws_subnet.public_subnet_a.id - route_table_id = aws_route_table.public_rt.id + tags = merge(module.this.tags, { + Name = "${module.this.id}-firewall-${local.azs[count.index]}" + }) } -resource "aws_route_table_association" "public_rta_b" { - subnet_id = aws_subnet.public_subnet_b.id - route_table_id = aws_route_table.public_rt.id -} +resource "aws_route_table_association" "firewall" { + count = module.this.enabled ? local.az_count : 0 -resource "aws_route_table_association" "public_rta_c" { - subnet_id = aws_subnet.public_subnet_c.id - route_table_id = aws_route_table.public_rt.id + subnet_id = aws_subnet.firewall[count.index].id + route_table_id = aws_route_table.firewall[count.index].id } diff --git a/infrastructure/modules/vpc/outputs.tf b/infrastructure/modules/vpc/outputs.tf index 86e1412b..621e9817 100644 --- a/infrastructure/modules/vpc/outputs.tf +++ b/infrastructure/modules/vpc/outputs.tf @@ -1,19 +1,135 @@ +################################################################ +# VPC +################################################################ + output "vpc_id" { - description = "ID of the VPC" - value = aws_vpc.vpc.id + description = "The ID of the VPC." + value = module.vpc.vpc_id } -output "private_subnet_ids" { - description = "IDs of the public subnets" - value = [aws_subnet.private_subnet_a.id, aws_subnet.private_subnet_b.id, aws_subnet.private_subnet_c.id] +output "vpc_arn" { + description = "The ARN of the VPC." + value = module.vpc.vpc_arn +} + +output "vpc_cidr_block" { + description = "The primary CIDR block of the VPC." + value = module.vpc.vpc_cidr_block +} + +################################################################ +# Availability zones +################################################################ + +output "azs" { + description = "The availability zones used by this VPC." + value = local.azs } +################################################################ +# Public subnets +################################################################ + output "public_subnet_ids" { - description = "IDs of the public subnets" - value = [aws_subnet.public_subnet_a.id, aws_subnet.public_subnet_b.id, aws_subnet.public_subnet_c.id] + description = "List of IDs of the public subnets." + value = module.vpc.public_subnets } -output "vpc_cidr_block" { - description = "CIDR range of the VPC" - value = aws_vpc.vpc.cidr_block +output "public_subnets_cidr_blocks" { + description = "List of CIDR blocks of the public subnets." + value = module.vpc.public_subnets_cidr_blocks +} + +output "public_route_table_ids" { + description = "List of IDs of the public route tables." + value = module.vpc.public_route_table_ids +} + +################################################################ +# Private subnets (NAT-routed) +################################################################ + +output "private_subnet_ids" { + description = "List of IDs of the private subnets (routed via NAT)." + value = module.vpc.private_subnets +} + +output "private_subnets_cidr_blocks" { + description = "List of CIDR blocks of the private subnets." + value = module.vpc.private_subnets_cidr_blocks +} + +output "private_route_table_ids" { + description = "List of IDs of the private route tables." + value = module.vpc.private_route_table_ids +} + +################################################################ +# Isolated subnets (no internet) +################################################################ + +output "isolated_subnet_ids" { + description = "List of IDs of the fully isolated subnets (no internet route)." + value = module.vpc.intra_subnets +} + +output "isolated_subnets_cidr_blocks" { + description = "List of CIDR blocks of the isolated subnets." + value = module.vpc.intra_subnets_cidr_blocks +} + +output "isolated_route_table_ids" { + description = "List of IDs of the isolated route tables." + value = module.vpc.intra_route_table_ids +} + +################################################################ +# Firewall subnets +################################################################ + +output "firewall_subnet_ids" { + description = "List of IDs of the firewall subnets." + value = aws_subnet.firewall[*].id +} + +output "firewall_subnets_cidr_blocks" { + description = "List of CIDR blocks of the firewall subnets." + value = aws_subnet.firewall[*].cidr_block +} + +output "firewall_route_table_ids" { + description = "List of IDs of the firewall route tables." + value = aws_route_table.firewall[*].id +} + +################################################################ +# NAT gateways +################################################################ + +output "nat_gateway_ids" { + description = "List of NAT Gateway IDs." + value = module.vpc.natgw_ids +} + +output "nat_public_ips" { + description = "List of public Elastic IPs created for NAT Gateways." + value = module.vpc.nat_public_ips +} + +################################################################ +# Internet gateway +################################################################ + +output "igw_id" { + description = "The ID of the Internet Gateway." + value = module.vpc.igw_id +} + +################################################################ +# Default security group +################################################################ + +output "default_security_group_id" { + description = "The ID of the default security group." + value = module.vpc.default_security_group_id } diff --git a/infrastructure/modules/vpc/variables.tf b/infrastructure/modules/vpc/variables.tf index dc480192..7888da75 100644 --- a/infrastructure/modules/vpc/variables.tf +++ b/infrastructure/modules/vpc/variables.tf @@ -1,22 +1,128 @@ -# tflint-ignore: terraform_unused_declarations -variable "environment" { - description = "The name of the Environment this is deployed into, for example CICD, NFT, UAT or PROD" +################################################################ +# VPC-specific inputs. +# +# Naming, tagging and the master `enabled` switch come from +# `context.tf` via `module.this`. +################################################################ + +variable "vpc_cidr" { + description = "The IPv4 CIDR block for the VPC. Must be a /16 for the default subnet auto-calculation to work." type = string + default = "10.0.0.0/16" + + validation { + condition = can(cidrhost(var.vpc_cidr, 0)) + error_message = "vpc_cidr must be a valid CIDR block." + } } -# tflint-ignore: terraform_unused_declarations -variable "name" { - description = "The name of the resource" - type = string - default = "" +################################################################ +# Subnet CIDR overrides +# +# When left empty (default) the module auto-calculates CIDRs +# from var.vpc_cidr +################################################################ + +variable "firewall_subnets" { + description = "Explicit /28 CIDR blocks for firewall subnets (one per AZ). Leave empty to auto-calculate." + type = list(string) + default = [] } -variable "name_prefix" { - description = "the environment and project" - type = string +variable "public_subnets" { + description = "Explicit /24 CIDR blocks for public subnets (one per AZ). Leave empty to auto-calculate." + type = list(string) + default = [] } -variable "vpc_cidr_prefix" { - description = "The CIDR block prefix for the VPC" - type = string +variable "private_subnets" { + description = "Explicit /23 CIDR blocks for private subnets with NAT (one per AZ). Leave empty to auto-calculate." + type = list(string) + default = [] +} + +variable "isolated_subnets" { + description = "Explicit /23 CIDR blocks for fully isolated subnets with no internet route (one per AZ). Leave empty to auto-calculate." + type = list(string) + default = [] +} + +################################################################ +# NAT Gateway +################################################################ + +variable "single_nat_gateway" { + description = "Provision a single shared NAT Gateway instead of one per AZ. Saves cost but reduces availability." + type = bool + default = false +} + +################################################################ +# DNS +################################################################ + +variable "enable_dns_hostnames" { + description = "Enable DNS hostnames in the VPC." + type = bool + default = true +} + +variable "enable_dns_support" { + description = "Enable DNS support in the VPC." + type = bool + default = true +} + +################################################################ +# Public subnets +################################################################ + +variable "map_public_ip_on_launch" { + description = "Auto-assign public IPs to instances launched in public subnets." + type = bool + default = false +} + +################################################################ +# Security defaults +################################################################ + +variable "manage_default_security_group" { + description = "Adopt and manage the default security group, removing all inline rules." + type = bool + default = true +} + +variable "manage_default_network_acl" { + description = "Adopt and manage the default network ACL." + type = bool + default = true +} + +################################################################ +# Subnet tags +################################################################ + +variable "public_subnet_tags" { + description = "Additional tags for the public subnets." + type = map(string) + default = {} +} + +variable "private_subnet_tags" { + description = "Additional tags for the private (NAT-routed) subnets." + type = map(string) + default = {} +} + +variable "isolated_subnet_tags" { + description = "Additional tags for the isolated (no-internet) subnets." + type = map(string) + default = {} +} + +variable "firewall_subnet_tags" { + description = "Additional tags for the firewall subnets." + type = map(string) + default = {} } diff --git a/infrastructure/modules/vpc/versions.tf b/infrastructure/modules/vpc/versions.tf index b699c78b..d2afd5f9 100644 --- a/infrastructure/modules/vpc/versions.tf +++ b/infrastructure/modules/vpc/versions.tf @@ -4,7 +4,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = ">= 6.47.0" + version = ">= 6.28" } } } From eb83faaec99b00af88bf9773e4bd916dc32cc7bf Mon Sep 17 00:00:00 2001 From: piravinth Date: Tue, 2 Jun 2026 19:55:29 +0100 Subject: [PATCH 02/13] add standlone vpc flow logs --- infrastructure/modules/vpc/main.tf | 74 +++++++++++++++++++++++++ infrastructure/modules/vpc/outputs.tf | 19 +++++++ infrastructure/modules/vpc/variables.tf | 33 +++++++++++ 3 files changed, 126 insertions(+) diff --git a/infrastructure/modules/vpc/main.tf b/infrastructure/modules/vpc/main.tf index 41e1186f..41ae431a 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -89,3 +89,77 @@ resource "aws_route_table_association" "firewall" { subnet_id = aws_subnet.firewall[count.index].id route_table_id = aws_route_table.firewall[count.index].id } + +################################################################ +# VPC Flow Logs +# +# Implemented as standalone resources rather than using the +# upstream module's built-in flow log inputs, which are +# deprecated in v6.x and will be removed in v7.0.0. +# See: https://github.com/terraform-aws-modules/terraform-aws-vpc/tree/master/modules/flow-log +# +# Sends flow logs to a dedicated CloudWatch Log Group with an +# IAM role scoped to that log group only. +################################################################ + +resource "aws_cloudwatch_log_group" "flow_log" { + count = module.this.enabled && var.enable_flow_log ? 1 : 0 + + name = "/vpc/${module.this.id}-flow-logs" + retention_in_days = var.flow_log_retention_in_days + kms_key_id = var.flow_log_kms_key_id + + tags = module.this.tags +} + +resource "aws_iam_role" "flow_log" { + count = module.this.enabled && var.enable_flow_log ? 1 : 0 + + name = "${module.this.id}-vpc-flow-logs-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "vpc-flow-logs.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) + + tags = module.this.tags +} + +resource "aws_iam_role_policy" "flow_log" { + count = module.this.enabled && var.enable_flow_log ? 1 : 0 + + name = "${module.this.id}-vpc-flow-logs-policy" + role = aws_iam_role.flow_log[0].id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams", + "logs:DescribeLogGroups" + ] + Resource = "${aws_cloudwatch_log_group.flow_log[0].arn}:*" + }] + }) +} + +resource "aws_flow_log" "this" { + count = module.this.enabled && var.enable_flow_log ? 1 : 0 + + vpc_id = module.vpc.vpc_id + log_destination_type = "cloud-watch-logs" + log_destination = aws_cloudwatch_log_group.flow_log[0].arn + iam_role_arn = aws_iam_role.flow_log[0].arn + traffic_type = var.flow_log_traffic_type + + tags = merge(module.this.tags, { + Name = "${module.this.id}-vpc-flow-log" + }) +} diff --git a/infrastructure/modules/vpc/outputs.tf b/infrastructure/modules/vpc/outputs.tf index 621e9817..10e64521 100644 --- a/infrastructure/modules/vpc/outputs.tf +++ b/infrastructure/modules/vpc/outputs.tf @@ -133,3 +133,22 @@ output "default_security_group_id" { description = "The ID of the default security group." value = module.vpc.default_security_group_id } + +################################################################ +# VPC Flow Logs +################################################################ + +output "flow_log_id" { + description = "The ID of the VPC Flow Log." + value = try(aws_flow_log.this[0].id, null) +} + +output "flow_log_cloudwatch_log_group_arn" { + description = "The ARN of the CloudWatch Log Group for VPC flow logs." + value = try(aws_cloudwatch_log_group.flow_log[0].arn, null) +} + +output "flow_log_iam_role_arn" { + description = "The ARN of the IAM role used by VPC flow logs." + value = try(aws_iam_role.flow_log[0].arn, null) +} diff --git a/infrastructure/modules/vpc/variables.tf b/infrastructure/modules/vpc/variables.tf index 7888da75..05533e3b 100644 --- a/infrastructure/modules/vpc/variables.tf +++ b/infrastructure/modules/vpc/variables.tf @@ -126,3 +126,36 @@ variable "firewall_subnet_tags" { type = map(string) default = {} } + +################################################################ +# VPC Flow Logs +################################################################ + +variable "enable_flow_log" { + description = "Enable VPC flow logs to CloudWatch Logs." + type = bool + default = true +} + +variable "flow_log_retention_in_days" { + description = "Number of days to retain VPC flow logs in CloudWatch." + type = number + default = 365 +} + +variable "flow_log_traffic_type" { + description = "The type of traffic to capture. Valid values: ACCEPT, REJECT, ALL." + type = string + default = "ALL" + + validation { + condition = contains(["ACCEPT", "REJECT", "ALL"], var.flow_log_traffic_type) + error_message = "flow_log_traffic_type must be one of ACCEPT, REJECT, ALL." + } +} + +variable "flow_log_kms_key_id" { + description = "ARN of a KMS key to encrypt the CloudWatch log group. Leave null for no encryption." + type = string + default = null +} From fe09c362d8fe2f8636cd131638c12c67fdd86b2a Mon Sep 17 00:00:00 2001 From: piravinth Date: Tue, 2 Jun 2026 20:01:44 +0100 Subject: [PATCH 03/13] update readme and versions.tf --- infrastructure/modules/vpc/readme.md | 166 ++++++++----------------- infrastructure/modules/vpc/versions.tf | 2 +- 2 files changed, 51 insertions(+), 117 deletions(-) diff --git a/infrastructure/modules/vpc/readme.md b/infrastructure/modules/vpc/readme.md index bfb5faa2..50428f0e 100644 --- a/infrastructure/modules/vpc/readme.md +++ b/infrastructure/modules/vpc/readme.md @@ -1,129 +1,63 @@ # VPC -This module will create an RDS Instance, This instance can then have multiple databases created within it. In the BSS environment we have a single RDS instance and all the developers have databases created within it which are created by GitHub pipelines. +Screening wrapper around the [`terraform-aws-modules/vpc/aws`](https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest) upstream module (v6.6.1), providing a standardised four-tier subnet layout. -## Preprequisites +## Subnet tiers -In order for this to work you will need to have a VPC running, there is a module defined to deploy a VPC in this repo +| Tier | Prefix | Purpose | +|------|--------|---------| +| Firewall | /28 | Network Firewall endpoints | +| Public | /24 | Public-facing resources, NAT gateways | +| Private | /23 | Private workloads with internet access via NAT | +| Isolated | /23 | Fully isolated, no internet route | -## Setup +Subnet CIDRs are auto-calculated from the VPC CIDR (assumes a /16) across all available AZs in the region. Explicit overrides are available via `firewall_subnets`, `public_subnets`, `private_subnets`, and `isolated_subnets` variables. -To use this module simply call it from your Terraform stack, here is an example Terraform file: +## Features -```terraform -terraform { - backend "s3" { - bucket = "nhse-bss-cicd-state" - key = "terraform-state/vpc.tfstate" - region = "eu-west-2" - encrypt = true - use_lockfile = true - } -} -provider "aws" { - region = "eu-west-2" - default_tags { - tags = { - Environment = var.environment - Terraform = "True" - Stack = "VPC" - } - } -} -module "vpc" { - source = "./modules/" - environment = var.environment - name = var.name - name_prefix = var.name_prefix -} -``` - -## Variables - -There are a few key values that need to be passed in: - -### prefix - -The `name_prefix` is the consistant part of the name which will be applied to all resources. In BSS that is `bss-cicd-en` for England and `bss-cicd-ni` for Northern Ireland. These would usually be passed in via either a `tfvar` file or via the command line interface from a pipeline, we use GitHub actions in the BSS team. - -### name - -This is the name of the resource, in BSS we are using `eks` as we have a single eks cluster which is shared by all developers, if you wanted multiple you would need to ensure the name was unique for each stack. - -### environment +- **Naming and tagging** via `context.tf` / `module.this` (tags module v2.5.0) +- **NAT gateways** — one per AZ by default, with `single_nat_gateway` option for cost savings +- **VPC Flow Logs** — enabled by default, sending to CloudWatch Logs with a 365-day retention. Implemented as standalone resources (upstream deprecated flow logs in v6.x, removing in v7.0.0) +- **Security defaults** — default security group adopted and stripped of all rules +- **Firewall subnets** — standalone resources (upstream module has no firewall tier) -This is the name of the environment it is deployed into, this might be `CICD`, `NTF`, `UFT` or `Prod`. +## Usage -### Optional variables - -There are many other variables which have default values which can be overwritten if desired, you can look in the variables.tf file for the full list which should all have descriptions explaining what they do. - - - - -## Requirements - -| Name | Version | -| ---- | ------- | -| [terraform](#requirement\_terraform) | >= 1.5.7 | -| [aws](#requirement\_aws) | >= 6.47.0 | - -## Providers - -| Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.50.0 | - -## Modules - -No modules. - -## Resources - -| Name | Type | -| ---- | ---- | -| [aws_eip.eip_a](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eip) | resource | -| [aws_eip.eip_b](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eip) | resource | -| [aws_eip.eip_c](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eip) | resource | -| [aws_internet_gateway.igw](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/internet_gateway) | resource | -| [aws_nat_gateway.nat_gw_a](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/nat_gateway) | resource | -| [aws_nat_gateway.nat_gw_b](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/nat_gateway) | resource | -| [aws_nat_gateway.nat_gw_c](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/nat_gateway) | resource | -| [aws_route_table.private_rt_a](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table) | resource | -| [aws_route_table.private_rt_b](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table) | resource | -| [aws_route_table.private_rt_c](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table) | resource | -| [aws_route_table.public_rt](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table) | resource | -| [aws_route_table_association.private_rta_a](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource | -| [aws_route_table_association.private_rta_b](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource | -| [aws_route_table_association.private_rta_c](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource | -| [aws_route_table_association.public_rta_a](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource | -| [aws_route_table_association.public_rta_b](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource | -| [aws_route_table_association.public_rta_c](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource | -| [aws_subnet.private_subnet_a](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet) | resource | -| [aws_subnet.private_subnet_b](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet) | resource | -| [aws_subnet.private_subnet_c](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet) | resource | -| [aws_subnet.public_subnet_a](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet) | resource | -| [aws_subnet.public_subnet_b](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet) | resource | -| [aws_subnet.public_subnet_c](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet) | resource | -| [aws_vpc.vpc](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc) | resource | +```terraform +module "vpc" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc?ref=" -## Inputs + environment = "dev" + service = "bcss" + name = "vpc" -| Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | -| [environment](#input\_environment) | The name of the Environment this is deployed into, for example CICD, NFT, UAT or PROD | `string` | n/a | yes | -| [name](#input\_name) | The name of the resource | `string` | `""` | no | -| [name\_prefix](#input\_name\_prefix) | the environment and project | `string` | n/a | yes | -| [vpc\_cidr\_prefix](#input\_vpc\_cidr\_prefix) | The CIDR block prefix for the VPC | `string` | n/a | yes | + vpc_cidr = "10.0.0.0/16" + single_nat_gateway = true # cost saving for non-prod -## Outputs + flow_log_kms_key_id = aws_kms_key.cloudwatch.arn # optional encryption +} +``` -| Name | Description | -| ---- | ----------- | -| [private\_subnet\_ids](#output\_private\_subnet\_ids) | IDs of the public subnets | -| [public\_subnet\_ids](#output\_public\_subnet\_ids) | IDs of the public subnets | -| [vpc\_cidr\_block](#output\_vpc\_cidr\_block) | CIDR range of the VPC | -| [vpc\_id](#output\_vpc\_id) | ID of the VPC | - - - +## Key variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `vpc_cidr` | VPC CIDR block (/16 for auto-calculation) | `10.0.0.0/16` | +| `single_nat_gateway` | Use one shared NAT instead of per-AZ | `false` | +| `enable_flow_log` | Enable VPC flow logs | `true` | +| `flow_log_retention_in_days` | CloudWatch log retention | `365` | +| `flow_log_traffic_type` | ACCEPT, REJECT, or ALL | `ALL` | +| `flow_log_kms_key_id` | KMS key ARN for log encryption | `null` | +| `map_public_ip_on_launch` | Auto-assign public IPs in public subnets | `false` | + +## Key outputs + +| Output | Description | +|--------|-------------| +| `vpc_id` | The VPC ID | +| `public_subnet_ids` | Public subnet IDs | +| `private_subnet_ids` | Private (NAT-routed) subnet IDs | +| `isolated_subnet_ids` | Isolated (no internet) subnet IDs | +| `firewall_subnet_ids` | Firewall subnet IDs | +| `nat_public_ips` | NAT gateway Elastic IPs | +| `flow_log_id` | VPC Flow Log ID | diff --git a/infrastructure/modules/vpc/versions.tf b/infrastructure/modules/vpc/versions.tf index d2afd5f9..ad55bb5a 100644 --- a/infrastructure/modules/vpc/versions.tf +++ b/infrastructure/modules/vpc/versions.tf @@ -4,7 +4,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = ">= 6.28" + version = ">= 6.28, < 7.0" } } } From e6f211eee9bbe0f3c988224c4c782206401e6380 Mon Sep 17 00:00:00 2001 From: piravinth Date: Wed, 3 Jun 2026 12:34:11 +0100 Subject: [PATCH 04/13] fix md fmt issue --- infrastructure/modules/vpc/readme.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infrastructure/modules/vpc/readme.md b/infrastructure/modules/vpc/readme.md index 50428f0e..b32fd769 100644 --- a/infrastructure/modules/vpc/readme.md +++ b/infrastructure/modules/vpc/readme.md @@ -2,7 +2,7 @@ Screening wrapper around the [`terraform-aws-modules/vpc/aws`](https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest) upstream module (v6.6.1), providing a standardised four-tier subnet layout. -## Subnet tiers +## subnet tiers | Tier | Prefix | Purpose | |------|--------|---------| @@ -11,7 +11,7 @@ Screening wrapper around the [`terraform-aws-modules/vpc/aws`](https://registry. | Private | /23 | Private workloads with internet access via NAT | | Isolated | /23 | Fully isolated, no internet route | -Subnet CIDRs are auto-calculated from the VPC CIDR (assumes a /16) across all available AZs in the region. Explicit overrides are available via `firewall_subnets`, `public_subnets`, `private_subnets`, and `isolated_subnets` variables. +subnet CIDRs are auto-calculated from the VPC CIDR (assumes a /16) across all available AZs in the region. Explicit overrides are available via `firewall_subnets`, `public_subnets`, `private_subnets`, and `isolated_subnets` variables. ## Features @@ -47,7 +47,7 @@ module "vpc" { | `enable_flow_log` | Enable VPC flow logs | `true` | | `flow_log_retention_in_days` | CloudWatch log retention | `365` | | `flow_log_traffic_type` | ACCEPT, REJECT, or ALL | `ALL` | -| `flow_log_kms_key_id` | KMS key ARN for log encryption | `null` | +| `flow_log_kms_key_id` | KMS key arn for log encryption | `null` | | `map_public_ip_on_launch` | Auto-assign public IPs in public subnets | `false` | ## Key outputs From 79506720a8b4925c8c55abe84ec399f1f3649dc4 Mon Sep 17 00:00:00 2001 From: piravinth Date: Tue, 16 Jun 2026 13:14:28 +0100 Subject: [PATCH 05/13] sync vpc module --- infrastructure/modules/vpc/context.tf | 3 +- infrastructure/modules/vpc/locals.tf | 37 +++- infrastructure/modules/vpc/main.tf | 219 +++++++++++++++++------- infrastructure/modules/vpc/outputs.tf | 50 ++++-- infrastructure/modules/vpc/variables.tf | 167 ++++++++++++++++-- 5 files changed, 383 insertions(+), 93 deletions(-) diff --git a/infrastructure/modules/vpc/context.tf b/infrastructure/modules/vpc/context.tf index cef74175..c28646f8 100644 --- a/infrastructure/modules/vpc/context.tf +++ b/infrastructure/modules/vpc/context.tf @@ -21,8 +21,9 @@ # module "this" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags?ref=v2.5.0" + source = "../tags" + enabled = var.enabled service = var.service project = var.project region = var.region diff --git a/infrastructure/modules/vpc/locals.tf b/infrastructure/modules/vpc/locals.tf index 34b61854..d21dfbb2 100644 --- a/infrastructure/modules/vpc/locals.tf +++ b/infrastructure/modules/vpc/locals.tf @@ -6,15 +6,40 @@ locals { azs = data.aws_availability_zones.available.names az_count = length(local.azs) - # Subnet CIDR allocation from the VPC CIDR (assumes /16) + # ───────────────────────────────────────────────────────────── + # Subnet CIDR allocation + # + # Uses cidrsubnets() to carve non-overlapping ranges from the + # VPC CIDR regardless of its prefix length. The target subnet + # sizes are controlled by var.firewall_subnet_prefix, etc. + # + # newbits = target_prefix - vpc_prefix + # ───────────────────────────────────────────────────────────── + vpc_prefix_length = tonumber(split("/", var.vpc_cidr)[1]) - auto_firewall_subnets = [for i in range(local.az_count) : cidrsubnet(cidrsubnet(var.vpc_cidr, 8, 0), 4, i)] - auto_public_subnets = [for i in range(local.az_count) : cidrsubnet(var.vpc_cidr, 8, 16 + i)] - auto_private_subnets = [for i in range(local.az_count) : cidrsubnet(var.vpc_cidr, 7, 16 + i)] - auto_isolated_subnets = [for i in range(local.az_count) : cidrsubnet(var.vpc_cidr, 7, 24 + i)] + firewall_newbits = var.firewall_subnet_prefix - local.vpc_prefix_length + public_newbits = var.public_subnet_prefix - local.vpc_prefix_length + private_newbits = var.private_subnet_prefix - local.vpc_prefix_length + intra_newbits = var.intra_subnet_prefix - local.vpc_prefix_length + # Build a flat list of newbits: [firewall x N, public x N, private x N, intra x N] + # cidrsubnets() guarantees non-overlapping, correctly-aligned CIDRs. + auto_newbits = concat( + [for _ in range(local.az_count) : local.firewall_newbits], + [for _ in range(local.az_count) : local.public_newbits], + [for _ in range(local.az_count) : local.private_newbits], + [for _ in range(local.az_count) : local.intra_newbits], + ) + auto_subnets = cidrsubnets(var.vpc_cidr, local.auto_newbits...) + + auto_firewall_subnets = slice(local.auto_subnets, 0, local.az_count) + auto_public_subnets = slice(local.auto_subnets, local.az_count, 2 * local.az_count) + auto_private_subnets = slice(local.auto_subnets, 2 * local.az_count, 3 * local.az_count) + auto_intra_subnets = slice(local.auto_subnets, 3 * local.az_count, 4 * local.az_count) + + # Allow explicit overrides per tier firewall_subnets = length(var.firewall_subnets) > 0 ? var.firewall_subnets : local.auto_firewall_subnets public_subnets = length(var.public_subnets) > 0 ? var.public_subnets : local.auto_public_subnets private_subnets = length(var.private_subnets) > 0 ? var.private_subnets : local.auto_private_subnets - isolated_subnets = length(var.isolated_subnets) > 0 ? var.isolated_subnets : local.auto_isolated_subnets + intra_subnets = length(var.intra_subnets) > 0 ? var.intra_subnets : local.auto_intra_subnets } diff --git a/infrastructure/modules/vpc/main.tf b/infrastructure/modules/vpc/main.tf index 41ae431a..6c65a0e1 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -1,12 +1,13 @@ ################################################################ # VPC Module # -# Screening wrapper -# `terraform-aws-modules/vpc/aws` module -# /28 firewall – Network Firewall endpoints -# /24 public – public-facing resources, NAT gateways -# /23 private – private workloads with internet via NAT -# /23 isolated – fully isolated, no internet route +# Screening wrapper around the `terraform-aws-modules/vpc/aws` +# community module. +# +# firewall – Network Firewall endpoints (default /28) +# public – public-facing resources, NAT GWs (default /24) +# private – workloads with internet via NAT (default /23) +# intra – no internet route (default /23) # # Naming and tagging are derived from context.tf via module.this. ################################################################ @@ -23,7 +24,18 @@ module "vpc" { azs = local.azs public_subnets = local.public_subnets private_subnets = local.private_subnets - intra_subnets = local.isolated_subnets + intra_subnets = local.intra_subnets + + # IGW: when Network Firewall routing is enabled, we create the + # IGW as a standalone resource so that public subnets do NOT get + # a default route to the IGW (that route goes via the firewall + # VPCE instead, injected at the stack level). + create_igw = !var.enable_network_firewall + + # Per-AZ public route tables: required when Network Firewall is + # enabled so that each AZ's outbound traffic traverses the + # firewall endpoint in the same AZ (symmetric routing). + create_multiple_public_route_tables = var.enable_network_firewall # NAT gateway configuration enable_nat_gateway = true @@ -34,6 +46,13 @@ module "vpc" { enable_dns_hostnames = var.enable_dns_hostnames enable_dns_support = var.enable_dns_support + # DHCP options + enable_dhcp_options = var.enable_dhcp_options + dhcp_options_domain_name = var.dhcp_options_domain_name + dhcp_options_domain_name_servers = var.dhcp_options_domain_name_servers + dhcp_options_ntp_servers = var.dhcp_options_ntp_servers + dhcp_options_tags = var.dhcp_options_tags + # Public subnets map_public_ip_on_launch = var.map_public_ip_on_launch @@ -48,9 +67,10 @@ module "vpc" { # Subnet tags public_subnet_tags = var.public_subnet_tags private_subnet_tags = var.private_subnet_tags - intra_subnet_tags = var.isolated_subnet_tags + intra_subnet_tags = var.intra_subnet_tags - tags = module.this.tags + # Exclude "Name" — the community module sets its own Name tags on all resources + tags = { for k, v in module.this.tags : k => v if k != "Name" } } ################################################################ @@ -61,7 +81,7 @@ module "vpc" { ################################################################ resource "aws_subnet" "firewall" { - count = module.this.enabled ? local.az_count : 0 + count = module.this.enabled && var.enable_network_firewall ? local.az_count : 0 vpc_id = module.vpc.vpc_id cidr_block = local.firewall_subnets[count.index] @@ -74,7 +94,7 @@ resource "aws_subnet" "firewall" { } resource "aws_route_table" "firewall" { - count = module.this.enabled ? local.az_count : 0 + count = module.this.enabled && var.enable_network_firewall ? local.az_count : 0 vpc_id = module.vpc.vpc_id @@ -84,82 +104,151 @@ resource "aws_route_table" "firewall" { } resource "aws_route_table_association" "firewall" { - count = module.this.enabled ? local.az_count : 0 + count = module.this.enabled && var.enable_network_firewall ? local.az_count : 0 subnet_id = aws_subnet.firewall[count.index].id route_table_id = aws_route_table.firewall[count.index].id } ################################################################ -# VPC Flow Logs +# Internet Gateway (Network Firewall routing mode) # -# Implemented as standalone resources rather than using the -# upstream module's built-in flow log inputs, which are -# deprecated in v6.x and will be removed in v7.0.0. -# See: https://github.com/terraform-aws-modules/terraform-aws-vpc/tree/master/modules/flow-log +# When enable_network_firewall = true, the community module's +# IGW is disabled (create_igw = false) so that public subnets +# do NOT get a default route to the IGW. Instead: +# - The IGW is created here as a standalone resource +# - Firewall subnets get 0.0.0.0/0 → IGW +# - Public subnets get 0.0.0.0/0 → firewall VPCE (injected +# at the stack level) # -# Sends flow logs to a dedicated CloudWatch Log Group with an -# IAM role scoped to that log group only. +# When enable_network_firewall = false, these resources are not +# created and the community module handles everything. ################################################################ -resource "aws_cloudwatch_log_group" "flow_log" { - count = module.this.enabled && var.enable_flow_log ? 1 : 0 +resource "aws_internet_gateway" "this" { + count = module.this.enabled && var.enable_network_firewall ? 1 : 0 + + vpc_id = module.vpc.vpc_id - name = "/vpc/${module.this.id}-flow-logs" - retention_in_days = var.flow_log_retention_in_days - kms_key_id = var.flow_log_kms_key_id + tags = merge(module.this.tags, { + Name = module.this.id + }) +} - tags = module.this.tags +resource "aws_route" "firewall_to_igw" { + count = module.this.enabled && var.enable_network_firewall ? local.az_count : 0 + + route_table_id = aws_route_table.firewall[count.index].id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this[0].id } -resource "aws_iam_role" "flow_log" { - count = module.this.enabled && var.enable_flow_log ? 1 : 0 +################################################################ +# IGW edge route table (Network Firewall mode) +# +# The edge route table is associated with the Internet Gateway. +# It routes return traffic (from the internet) destined for each +# public subnet CIDR through the firewall endpoint in the same +# AZ, ensuring symmetric routing for stateful inspection. +# +# The actual per-CIDR routes are injected at the stack level +# because they depend on the Network Firewall module's VPCE IDs. +################################################################ + +resource "aws_route_table" "edge" { + count = module.this.enabled && var.enable_network_firewall ? 1 : 0 - name = "${module.this.id}-vpc-flow-logs-role" + vpc_id = module.vpc.vpc_id - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - Effect = "Allow" - Principal = { Service = "vpc-flow-logs.amazonaws.com" } - Action = "sts:AssumeRole" - }] + tags = merge(module.this.tags, { + Name = "${module.this.id}-edge" }) +} - tags = module.this.tags +resource "aws_route_table_association" "edge" { + count = module.this.enabled && var.enable_network_firewall ? 1 : 0 + + gateway_id = aws_internet_gateway.this[0].id + route_table_id = aws_route_table.edge[0].id } -resource "aws_iam_role_policy" "flow_log" { - count = module.this.enabled && var.enable_flow_log ? 1 : 0 - - name = "${module.this.id}-vpc-flow-logs-policy" - role = aws_iam_role.flow_log[0].id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - Effect = "Allow" - Action = [ - "logs:CreateLogStream", - "logs:PutLogEvents", - "logs:DescribeLogStreams", - "logs:DescribeLogGroups" - ] - Resource = "${aws_cloudwatch_log_group.flow_log[0].arn}:*" - }] - }) +################################################################ +# VPC Flow Logs +# +# Uses the standalone flow-log submodule from +# terraform-aws-modules/vpc/aws (the root module's built-in +# flow log support is deprecated in v6.x, removed in v7.0.0). +# +# The submodule creates: +# - CloudWatch Log Group +# - IAM Role with scoped trust policy +# - VPC Flow Log resource +################################################################ + +module "flow_log" { + source = "terraform-aws-modules/vpc/aws//modules/flow-log" + version = "6.6.1" + + create = module.this.enabled && var.enable_flow_log + + name = "${module.this.id}-flow-log" + vpc_id = module.vpc.vpc_id + + # CloudWatch destination + log_destination_type = "cloud-watch-logs" + cloudwatch_log_group_name = "/vpc/${module.this.id}/flow-logs" + cloudwatch_log_group_use_name_prefix = false + cloudwatch_log_group_retention_in_days = var.flow_log_retention_in_days + cloudwatch_log_group_kms_key_id = var.flow_log_kms_key_id + + # IAM role (created by the submodule with scoped trust policy) + create_iam_role = true + iam_role_name = "${module.this.id}-flow-logs" + iam_role_use_name_prefix = false + + traffic_type = var.flow_log_traffic_type + max_aggregation_interval = var.flow_log_max_aggregation_interval + + cloudwatch_log_group_tags = var.cloudwatch_log_group_tags + flow_log_tags = var.flow_log_tags + iam_role_tags = var.iam_role_tags + + tags = module.this.tags } -resource "aws_flow_log" "this" { - count = module.this.enabled && var.enable_flow_log ? 1 : 0 +################################################################ +# VPC Endpoints +# +# Uses the standalone vpc-endpoints submodule from +# terraform-aws-modules/vpc/aws. +# +# Interface endpoints default to intra subnets (no internet +# route needed – they use AWS PrivateLink). Override per-endpoint +# with subnet_ids inside the endpoints map. +# +# Gateway endpoints (S3, DynamoDB) are attached to route tables +# specified per-endpoint via route_table_ids. +# +# Security groups are NOT managed here – callers should create +# them at the stack level using the security-group module and +# pass security_group_ids per-endpoint. +################################################################ + +module "vpc_endpoints" { + source = "terraform-aws-modules/vpc/aws//modules/vpc-endpoints" + version = "6.6.1" - vpc_id = module.vpc.vpc_id - log_destination_type = "cloud-watch-logs" - log_destination = aws_cloudwatch_log_group.flow_log[0].arn - iam_role_arn = aws_iam_role.flow_log[0].arn - traffic_type = var.flow_log_traffic_type + create = module.this.enabled && var.create_vpc_endpoints - tags = merge(module.this.tags, { - Name = "${module.this.id}-vpc-flow-log" - }) + vpc_id = module.vpc.vpc_id + + # Default subnet placement: intra (no internet route) + subnet_ids = module.vpc.intra_subnets + + # Security groups are managed at the stack level + create_security_group = false + + endpoints = var.vpc_endpoints + + tags = module.this.tags } diff --git a/infrastructure/modules/vpc/outputs.tf b/infrastructure/modules/vpc/outputs.tf index 10e64521..56f47a68 100644 --- a/infrastructure/modules/vpc/outputs.tf +++ b/infrastructure/modules/vpc/outputs.tf @@ -65,21 +65,21 @@ output "private_route_table_ids" { } ################################################################ -# Isolated subnets (no internet) +# Intra subnets (no internet) ################################################################ -output "isolated_subnet_ids" { - description = "List of IDs of the fully isolated subnets (no internet route)." +output "intra_subnet_ids" { + description = "List of IDs of the intra subnets (no internet route)." value = module.vpc.intra_subnets } -output "isolated_subnets_cidr_blocks" { - description = "List of CIDR blocks of the isolated subnets." +output "intra_subnets_cidr_blocks" { + description = "List of CIDR blocks of the intra subnets." value = module.vpc.intra_subnets_cidr_blocks } -output "isolated_route_table_ids" { - description = "List of IDs of the isolated route tables." +output "intra_route_table_ids" { + description = "List of IDs of the intra route tables." value = module.vpc.intra_route_table_ids } @@ -122,7 +122,12 @@ output "nat_public_ips" { output "igw_id" { description = "The ID of the Internet Gateway." - value = module.vpc.igw_id + value = var.enable_network_firewall ? try(aws_internet_gateway.this[0].id, null) : module.vpc.igw_id +} + +output "igw_arn" { + description = "The ARN of the Internet Gateway." + value = var.enable_network_firewall ? try(aws_internet_gateway.this[0].arn, null) : module.vpc.igw_arn } ################################################################ @@ -140,15 +145,38 @@ output "default_security_group_id" { output "flow_log_id" { description = "The ID of the VPC Flow Log." - value = try(aws_flow_log.this[0].id, null) + value = module.flow_log.id +} + +output "flow_log_arn" { + description = "The ARN of the VPC Flow Log." + value = module.flow_log.arn } output "flow_log_cloudwatch_log_group_arn" { description = "The ARN of the CloudWatch Log Group for VPC flow logs." - value = try(aws_cloudwatch_log_group.flow_log[0].arn, null) + value = module.flow_log.cloudwatch_log_group_arn } output "flow_log_iam_role_arn" { description = "The ARN of the IAM role used by VPC flow logs." - value = try(aws_iam_role.flow_log[0].arn, null) + value = module.flow_log.iam_role_arn +} + +################################################################ +# VPC Endpoints +################################################################ + +output "vpc_endpoints" { + description = "Map of VPC endpoints created, keyed by the logical name." + value = module.vpc_endpoints.endpoints +} + +################################################################ +# Edge route table +################################################################ + +output "edge_route_table_id" { + description = "ID of the IGW edge route table (only when enable_network_firewall = true)." + value = try(aws_route_table.edge[0].id, null) } diff --git a/infrastructure/modules/vpc/variables.tf b/infrastructure/modules/vpc/variables.tf index 05533e3b..950abeaa 100644 --- a/infrastructure/modules/vpc/variables.tf +++ b/infrastructure/modules/vpc/variables.tf @@ -4,11 +4,27 @@ # Naming, tagging and the master `enabled` switch come from # `context.tf` via `module.this`. ################################################################ +variable "enable_network_firewall" { + description = <<-EOT + When true, the VPC module creates firewall subnets, takes over + IGW management from the community module, and reconfigures + routing for AWS Network Firewall inspection: + - Firewall subnets created as standalone resources + - IGW created as a standalone resource (community module's create_igw = false) + - Firewall subnets get a default route (0.0.0.0/0) to the IGW + - Public subnet default route is NOT created (callers must + inject 0.0.0.0/0 → firewall VPCE at the stack level) + When false (default), no firewall subnets are created, the + community module creates the IGW and public → IGW route as + normal — no Network Firewall in the path. + EOT + type = bool + default = false +} variable "vpc_cidr" { - description = "The IPv4 CIDR block for the VPC. Must be a /16 for the default subnet auto-calculation to work." + description = "The IPv4 CIDR block for the VPC. Works with any prefix length – subnet sizes are controlled by the *_subnet_prefix variables." type = string - default = "10.0.0.0/16" validation { condition = can(cidrhost(var.vpc_cidr, 0)) @@ -16,33 +32,64 @@ variable "vpc_cidr" { } } +################################################################ +# Subnet prefix lengths +# +# Control the size of each subnet tier. The module uses +# cidrsubnets() to carve non-overlapping ranges automatically. +################################################################ + +variable "firewall_subnet_prefix" { + description = "Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each)." + type = number + default = 28 +} + +variable "public_subnet_prefix" { + description = "Prefix length for public subnets (e.g. 24 = /24, 256 IPs each)." + type = number + default = 24 +} + +variable "private_subnet_prefix" { + description = "Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each)." + type = number + default = 23 +} + +variable "intra_subnet_prefix" { + description = "Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each)." + type = number + default = 23 +} + ################################################################ # Subnet CIDR overrides # # When left empty (default) the module auto-calculates CIDRs -# from var.vpc_cidr +# from var.vpc_cidr using the prefix lengths above. ################################################################ variable "firewall_subnets" { - description = "Explicit /28 CIDR blocks for firewall subnets (one per AZ). Leave empty to auto-calculate." + description = "Explicit CIDR blocks for firewall subnets (one per AZ). Leave empty to auto-calculate." type = list(string) default = [] } variable "public_subnets" { - description = "Explicit /24 CIDR blocks for public subnets (one per AZ). Leave empty to auto-calculate." + description = "Explicit CIDR blocks for public subnets (one per AZ). Leave empty to auto-calculate." type = list(string) default = [] } variable "private_subnets" { - description = "Explicit /23 CIDR blocks for private subnets with NAT (one per AZ). Leave empty to auto-calculate." + description = "Explicit CIDR blocks for private subnets with NAT (one per AZ). Leave empty to auto-calculate." type = list(string) default = [] } -variable "isolated_subnets" { - description = "Explicit /23 CIDR blocks for fully isolated subnets with no internet route (one per AZ). Leave empty to auto-calculate." +variable "intra_subnets" { + description = "Explicit CIDR blocks for intra subnets with no internet route (one per AZ). Leave empty to auto-calculate." type = list(string) default = [] } @@ -73,6 +120,40 @@ variable "enable_dns_support" { default = true } +################################################################ +# DHCP Options +################################################################ + +variable "enable_dhcp_options" { + description = "Create a custom DHCP option set and associate it with the VPC." + type = bool + default = false +} + +variable "dhcp_options_domain_name" { + description = "The suffix domain name to use by default when resolving non-FQDNs." + type = string + default = "" +} + +variable "dhcp_options_domain_name_servers" { + description = "List of DNS server addresses for the DHCP option set. Use ['AmazonProvidedDNS'] for the default VPC resolver, or Route 53 Resolver inbound endpoint IPs." + type = list(string) + default = ["AmazonProvidedDNS"] +} + +variable "dhcp_options_ntp_servers" { + description = "List of NTP servers for the DHCP option set." + type = list(string) + default = [] +} + +variable "dhcp_options_tags" { + description = "Additional tags for the DHCP option set." + type = map(string) + default = {} +} + ################################################################ # Public subnets ################################################################ @@ -115,8 +196,8 @@ variable "private_subnet_tags" { default = {} } -variable "isolated_subnet_tags" { - description = "Additional tags for the isolated (no-internet) subnets." +variable "intra_subnet_tags" { + description = "Additional tags for the intra (no-internet) subnets." type = map(string) default = {} } @@ -159,3 +240,69 @@ variable "flow_log_kms_key_id" { type = string default = null } + +variable "flow_log_max_aggregation_interval" { + description = "The maximum interval of time (seconds) during which a flow of packets is captured. Valid values: 60 (1 min) or 600 (10 min)." + type = number + default = 600 + + validation { + condition = contains([60, 600], var.flow_log_max_aggregation_interval) + error_message = "flow_log_max_aggregation_interval must be 60 or 600." + } +} + +variable "cloudwatch_log_group_tags" { + description = "Additional tags for the CloudWatch log group." + type = map(string) + default = {} +} + +variable "flow_log_tags" { + description = "Additional tags for the VPC flow log." + type = map(string) + default = {} +} + +variable "iam_role_tags" { + description = "Additional tags for the IAM role used by the VPC flow log." + type = map(string) + default = {} +} + +################################################################ +# VPC Endpoints +################################################################ + +variable "create_vpc_endpoints" { + description = "Whether to create VPC endpoints." + type = bool + default = true +} + +variable "vpc_endpoints" { + description = <<-EOT + Map of VPC endpoints to create. Each key is a logical name, + each value is passed through to the upstream vpc-endpoints + submodule. + + Interface endpoints are placed in intra subnets by default. + Security groups must be created at the stack level and passed + per-endpoint via `security_group_ids`. + + Gateway endpoints require `service_type = "Gateway"` and + `route_table_ids`. + + Supported per-endpoint attributes: + service - AWS service name (e.g. "s3", "ecr.api") + service_type - "Interface" (default) or "Gateway" + policy - JSON endpoint policy document + subnet_ids - Override default intra subnets + security_group_ids - Security group IDs for this endpoint + private_dns_enabled - Enable private DNS (Interface only) + route_table_ids - Route table IDs (Gateway only) + tags - Per-endpoint tags + EOT + type = any + default = {} +} From ec99fda5d971a1155f9e37070957cc009e993ac6 Mon Sep 17 00:00:00 2001 From: piravinth Date: Tue, 16 Jun 2026 13:18:20 +0100 Subject: [PATCH 06/13] ignore pre-commit-config.yaml false positive. --- scripts/config/gitleaks.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/config/gitleaks.toml b/scripts/config/gitleaks.toml index af5f0bb7..61b22278 100644 --- a/scripts/config/gitleaks.toml +++ b/scripts/config/gitleaks.toml @@ -16,4 +16,4 @@ regexes = [ ] [allowlist] -paths = ['''.terraform.lock.hcl''', '''poetry.lock''', '''yarn.lock'''] +paths = ['''.terraform.lock.hcl''', '''poetry.lock''', '''yarn.lock''', '''.pre-commit-config.yaml'''] From 92036debd113e816928f238e4ed4dc38c03b7f02 Mon Sep 17 00:00:00 2001 From: piravinth Date: Tue, 16 Jun 2026 13:22:02 +0100 Subject: [PATCH 07/13] fmt tf --- infrastructure/modules/vpc/locals.tf | 4 ++-- infrastructure/modules/vpc/main.tf | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/infrastructure/modules/vpc/locals.tf b/infrastructure/modules/vpc/locals.tf index d21dfbb2..4da4b9ec 100644 --- a/infrastructure/modules/vpc/locals.tf +++ b/infrastructure/modules/vpc/locals.tf @@ -20,7 +20,7 @@ locals { firewall_newbits = var.firewall_subnet_prefix - local.vpc_prefix_length public_newbits = var.public_subnet_prefix - local.vpc_prefix_length private_newbits = var.private_subnet_prefix - local.vpc_prefix_length - intra_newbits = var.intra_subnet_prefix - local.vpc_prefix_length + intra_newbits = var.intra_subnet_prefix - local.vpc_prefix_length # Build a flat list of newbits: [firewall x N, public x N, private x N, intra x N] # cidrsubnets() guarantees non-overlapping, correctly-aligned CIDRs. @@ -35,7 +35,7 @@ locals { auto_firewall_subnets = slice(local.auto_subnets, 0, local.az_count) auto_public_subnets = slice(local.auto_subnets, local.az_count, 2 * local.az_count) auto_private_subnets = slice(local.auto_subnets, 2 * local.az_count, 3 * local.az_count) - auto_intra_subnets = slice(local.auto_subnets, 3 * local.az_count, 4 * local.az_count) + auto_intra_subnets = slice(local.auto_subnets, 3 * local.az_count, 4 * local.az_count) # Allow explicit overrides per tier firewall_subnets = length(var.firewall_subnets) > 0 ? var.firewall_subnets : local.auto_firewall_subnets diff --git a/infrastructure/modules/vpc/main.tf b/infrastructure/modules/vpc/main.tf index 6c65a0e1..1793c7a2 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -202,16 +202,16 @@ module "flow_log" { cloudwatch_log_group_kms_key_id = var.flow_log_kms_key_id # IAM role (created by the submodule with scoped trust policy) - create_iam_role = true - iam_role_name = "${module.this.id}-flow-logs" - iam_role_use_name_prefix = false + create_iam_role = true + iam_role_name = "${module.this.id}-flow-logs" + iam_role_use_name_prefix = false traffic_type = var.flow_log_traffic_type max_aggregation_interval = var.flow_log_max_aggregation_interval - cloudwatch_log_group_tags = var.cloudwatch_log_group_tags - flow_log_tags = var.flow_log_tags - iam_role_tags = var.iam_role_tags + cloudwatch_log_group_tags = var.cloudwatch_log_group_tags + flow_log_tags = var.flow_log_tags + iam_role_tags = var.iam_role_tags tags = module.this.tags } From a8664132238cf09f7c976c85b7791a5a7c3c8f32 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 19 Jun 2026 16:04:49 +0100 Subject: [PATCH 08/13] style: format code for consistency in VPC module locals and main files N.B. This is a copy taken from the 'shared' stack implementation, and has been pushed without validation checks --- infrastructure/modules/vpc/locals.tf | 4 ++-- infrastructure/modules/vpc/main.tf | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/infrastructure/modules/vpc/locals.tf b/infrastructure/modules/vpc/locals.tf index 4da4b9ec..d21dfbb2 100644 --- a/infrastructure/modules/vpc/locals.tf +++ b/infrastructure/modules/vpc/locals.tf @@ -20,7 +20,7 @@ locals { firewall_newbits = var.firewall_subnet_prefix - local.vpc_prefix_length public_newbits = var.public_subnet_prefix - local.vpc_prefix_length private_newbits = var.private_subnet_prefix - local.vpc_prefix_length - intra_newbits = var.intra_subnet_prefix - local.vpc_prefix_length + intra_newbits = var.intra_subnet_prefix - local.vpc_prefix_length # Build a flat list of newbits: [firewall x N, public x N, private x N, intra x N] # cidrsubnets() guarantees non-overlapping, correctly-aligned CIDRs. @@ -35,7 +35,7 @@ locals { auto_firewall_subnets = slice(local.auto_subnets, 0, local.az_count) auto_public_subnets = slice(local.auto_subnets, local.az_count, 2 * local.az_count) auto_private_subnets = slice(local.auto_subnets, 2 * local.az_count, 3 * local.az_count) - auto_intra_subnets = slice(local.auto_subnets, 3 * local.az_count, 4 * local.az_count) + auto_intra_subnets = slice(local.auto_subnets, 3 * local.az_count, 4 * local.az_count) # Allow explicit overrides per tier firewall_subnets = length(var.firewall_subnets) > 0 ? var.firewall_subnets : local.auto_firewall_subnets diff --git a/infrastructure/modules/vpc/main.tf b/infrastructure/modules/vpc/main.tf index 1793c7a2..6c65a0e1 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -202,16 +202,16 @@ module "flow_log" { cloudwatch_log_group_kms_key_id = var.flow_log_kms_key_id # IAM role (created by the submodule with scoped trust policy) - create_iam_role = true - iam_role_name = "${module.this.id}-flow-logs" - iam_role_use_name_prefix = false + create_iam_role = true + iam_role_name = "${module.this.id}-flow-logs" + iam_role_use_name_prefix = false traffic_type = var.flow_log_traffic_type max_aggregation_interval = var.flow_log_max_aggregation_interval - cloudwatch_log_group_tags = var.cloudwatch_log_group_tags - flow_log_tags = var.flow_log_tags - iam_role_tags = var.iam_role_tags + cloudwatch_log_group_tags = var.cloudwatch_log_group_tags + flow_log_tags = var.flow_log_tags + iam_role_tags = var.iam_role_tags tags = module.this.tags } From 74ff0a2ea81236b65466d71fd51f212f701867dc Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 19 Jun 2026 16:27:30 +0100 Subject: [PATCH 09/13] feat(vpc)replace legacy vpc module with upstream wrapper) feat(vpc): Add comprehensive README for VPC module with usage examples and conventions fix(vpc): Update context.tf to include tflint ignore comments refactor(vpc): Clean up locals.tf for better readability refactor(vpc): Standardize IAM role parameters in main.tf for flow logs chore(vpc): Remove outdated readme.md in favor of new README.md chore(vpc): Update required Terraform and AWS provider versions in versions.tf style(vocab): Update vocabulary for Vale to include case variations for 'arn', 'iam', and 'vpc' --- .../modules/vpc/.terraform.lock.hcl | 44 +++++++++---------- .../modules/vpc/{readme.md => README.md} | 13 ++++++ infrastructure/modules/vpc/context.tf | 2 + infrastructure/modules/vpc/locals.tf | 4 +- infrastructure/modules/vpc/main.tf | 12 ++--- infrastructure/modules/vpc/versions.tf | 4 +- 6 files changed, 47 insertions(+), 32 deletions(-) rename infrastructure/modules/vpc/{readme.md => README.md} (87%) diff --git a/infrastructure/modules/vpc/.terraform.lock.hcl b/infrastructure/modules/vpc/.terraform.lock.hcl index e084c58b..989d3734 100644 --- a/infrastructure/modules/vpc/.terraform.lock.hcl +++ b/infrastructure/modules/vpc/.terraform.lock.hcl @@ -2,29 +2,29 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "6.50.0" - constraints = ">= 6.47.0" + version = "6.51.0" + constraints = ">= 6.14.0, >= 6.28.0, >= 6.42.0" hashes = [ - "h1:8y10QFtGLHl3pF/R1/hO7VCPHTexm1whc0BfuG4uruw=", - "h1:D8uNiOpl3UkAX4zI5T47ALMiRFXTa1XfdQC+TBu3RmE=", - "h1:Uf2LlEibaBdksEUkOoiQbzEbkIgOR6tUE/0tCd36Xzk=", - "h1:gnyVeH3L2erQ/di0a4x5i0AlsIcdLjyK5+Vmbf3qyck=", - "h1:mNg4vBXXqbO0hY2jCxhOyKVrnjEO0viTG2EY4oAlWaQ=", - "zh:0072806bb262c6d86bc25b4a75750e469881144c14818afdba7b82db840e1588", - "zh:1ebc2dae335dad7a8b16a1985b69a63a14954282bb44fdba7d5103f77551ac7b", - "zh:2dab48fe8f3193b8216d578ac1e3674fa566435cc7dbce2953d55b72e31d0241", - "zh:2fc3d3029c2b7429472391ef339672e1fca8e6ff32c8a519bf3acedafa7e24fe", - "zh:38a36e64e7212f6cedac861ea4d449cce07131b3378de601bf9d49a99e000208", - "zh:3ac70758ed251ce78b7f541a5a79cc6fe56474412783ae1decef719bdd0f30bf", - "zh:4385d3903e685bddb2b8005b4eb7db89f030267d4d03c7d792d2f5e739cc874a", - "zh:4cce0760b87fbafd51f30faec2a737f4183b7c615f4a86557f7d3c893a610dc5", - "zh:4feaeed18694239b896c6415d9a1e5ef89e1da4f4ad60924aa0522adeb1f6599", - "zh:502fca2be1c95f443c3e67d0555601d1de65b4ca82d197c059e9c868360e3a0a", - "zh:57d037f6fdd045f2660909c3bdface9622d81165ce647479cba98d1f353c5eab", - "zh:5dc5a0b915c2ac5256d909458f5c8e40b35f78b3a36ea893c86624eaf6c54e37", + "h1:017ISHZZBI+yeqA4AAtgLQJC7Lhd4wYM7tEKYmlk/7Y=", + "h1:4c8zjgtGH0QgP+p/cF1UqdqkvD7V5i0ZxqslieZLTbc=", + "h1:QWxF+1ePJ4qFCHEc6PyHNeXc865wLvrWVl71d/nABa8=", + "h1:aPBmqoiYqfrIgCGwzuemljkOXuGCYQRTXo91nQxrE+s=", + "h1:bclp+xS1fYeOCil0XZO6mKvEeHFESt5K/XotVSZND54=", + "zh:03fcea0a1ea2ca81d62d4d2e2961181bef9068b1c701f2cddc4aa5fac105818a", + "zh:1213944cd623143974ea5c9b70b22ae1ccca33d743924c149ed089d34b8e08b4", + "zh:190a46da0c69082b74da48238ce134d2fc9893e09122ac249c5689f88eab7e13", + "zh:1b312a4b53fa3cf731f95e674c033865feea5455f163b86136f2614424637293", + "zh:2b319814806222c5aba196b1a78756a6b36dc5c91f85edda349234d8a2f20a6a", + "zh:2bddf92c8efc6ad445a2eb8a0e5f88742a0596392c3a4ebc350ebb4105a4a96d", + "zh:3bef0c4f675c09034ff017cf899977b1765b2c0b3d1e489bcb06a5fcac316e2d", + "zh:47c46b5aa22199638fed5c93b195bbfd1182a1408edad4e5c39d4a73a04493f6", + "zh:5f808699650f6db961964466c77f5a581eab142a91c2e54810bb09b6f2fcd3f2", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:b84c87c58a320adbb2c74a4cad03ae5aac7f2eae21db26f00fdde98c8c4d4523", - "zh:c895f1d5cbcbeff77850ac99efd36bde0048d4e909b296882331b9b9ebf48cfa", - "zh:ead82831683619124597a1f170dd31e9b293e9cf22f558cb166d5e734fcd11e4", + "zh:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", ] } diff --git a/infrastructure/modules/vpc/readme.md b/infrastructure/modules/vpc/README.md similarity index 87% rename from infrastructure/modules/vpc/readme.md rename to infrastructure/modules/vpc/README.md index b32fd769..3db789b4 100644 --- a/infrastructure/modules/vpc/readme.md +++ b/infrastructure/modules/vpc/README.md @@ -2,6 +2,19 @@ Screening wrapper around the [`terraform-aws-modules/vpc/aws`](https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest) upstream module (v6.6.1), providing a standardised four-tier subnet layout. +## Breaking change + +This module is a breaking replacement for the original local `vpc` module. + +Consumers must review and update module calls before upgrading, including: + +- Input variables and defaults +- Output names and semantics +- Routing behaviour when enabling Network Firewall mode +- Flow log configuration and tagging + +Treat adoption of this module as a migration, not a drop-in swap. + ## subnet tiers | Tier | Prefix | Purpose | diff --git a/infrastructure/modules/vpc/context.tf b/infrastructure/modules/vpc/context.tf index c28646f8..b644ebfb 100644 --- a/infrastructure/modules/vpc/context.tf +++ b/infrastructure/modules/vpc/context.tf @@ -1,3 +1,5 @@ +# tflint-ignore-file: terraform_standard_module_structure, terraform_unused_declarations +# # # ONLY EDIT THIS FILE IN github.com/NHSDigital/screening-terraform-modules-aws/infrastructure/modules/tags # All other instances of this file should be a copy of that one diff --git a/infrastructure/modules/vpc/locals.tf b/infrastructure/modules/vpc/locals.tf index d21dfbb2..4da4b9ec 100644 --- a/infrastructure/modules/vpc/locals.tf +++ b/infrastructure/modules/vpc/locals.tf @@ -20,7 +20,7 @@ locals { firewall_newbits = var.firewall_subnet_prefix - local.vpc_prefix_length public_newbits = var.public_subnet_prefix - local.vpc_prefix_length private_newbits = var.private_subnet_prefix - local.vpc_prefix_length - intra_newbits = var.intra_subnet_prefix - local.vpc_prefix_length + intra_newbits = var.intra_subnet_prefix - local.vpc_prefix_length # Build a flat list of newbits: [firewall x N, public x N, private x N, intra x N] # cidrsubnets() guarantees non-overlapping, correctly-aligned CIDRs. @@ -35,7 +35,7 @@ locals { auto_firewall_subnets = slice(local.auto_subnets, 0, local.az_count) auto_public_subnets = slice(local.auto_subnets, local.az_count, 2 * local.az_count) auto_private_subnets = slice(local.auto_subnets, 2 * local.az_count, 3 * local.az_count) - auto_intra_subnets = slice(local.auto_subnets, 3 * local.az_count, 4 * local.az_count) + auto_intra_subnets = slice(local.auto_subnets, 3 * local.az_count, 4 * local.az_count) # Allow explicit overrides per tier firewall_subnets = length(var.firewall_subnets) > 0 ? var.firewall_subnets : local.auto_firewall_subnets diff --git a/infrastructure/modules/vpc/main.tf b/infrastructure/modules/vpc/main.tf index 6c65a0e1..1793c7a2 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -202,16 +202,16 @@ module "flow_log" { cloudwatch_log_group_kms_key_id = var.flow_log_kms_key_id # IAM role (created by the submodule with scoped trust policy) - create_iam_role = true - iam_role_name = "${module.this.id}-flow-logs" - iam_role_use_name_prefix = false + create_iam_role = true + iam_role_name = "${module.this.id}-flow-logs" + iam_role_use_name_prefix = false traffic_type = var.flow_log_traffic_type max_aggregation_interval = var.flow_log_max_aggregation_interval - cloudwatch_log_group_tags = var.cloudwatch_log_group_tags - flow_log_tags = var.flow_log_tags - iam_role_tags = var.iam_role_tags + cloudwatch_log_group_tags = var.cloudwatch_log_group_tags + flow_log_tags = var.flow_log_tags + iam_role_tags = var.iam_role_tags tags = module.this.tags } diff --git a/infrastructure/modules/vpc/versions.tf b/infrastructure/modules/vpc/versions.tf index ad55bb5a..cb30fe5c 100644 --- a/infrastructure/modules/vpc/versions.tf +++ b/infrastructure/modules/vpc/versions.tf @@ -1,10 +1,10 @@ terraform { - required_version = ">= 1.5.7" + required_version = ">= 1.13" required_providers { aws = { source = "hashicorp/aws" - version = ">= 6.28, < 7.0" + version = ">= 6.42" } } } From 204a4256614f1cdef367c7fdc922f6356eb9ed9e Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 19 Jun 2026 17:48:00 +0100 Subject: [PATCH 10/13] docs: update README.md with subnet tiers and module requirements --- infrastructure/modules/vpc/README.md | 150 ++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 3 deletions(-) diff --git a/infrastructure/modules/vpc/README.md b/infrastructure/modules/vpc/README.md index 3db789b4..03cb27f9 100644 --- a/infrastructure/modules/vpc/README.md +++ b/infrastructure/modules/vpc/README.md @@ -18,7 +18,7 @@ Treat adoption of this module as a migration, not a drop-in swap. ## subnet tiers | Tier | Prefix | Purpose | -|------|--------|---------| +| --- | --- | --- | | Firewall | /28 | Network Firewall endpoints | | Public | /24 | Public-facing resources, NAT gateways | | Private | /23 | Private workloads with internet access via NAT | @@ -54,7 +54,7 @@ module "vpc" { ## Key variables | Variable | Description | Default | -|----------|-------------|---------| +| --- | --- | --- | | `vpc_cidr` | VPC CIDR block (/16 for auto-calculation) | `10.0.0.0/16` | | `single_nat_gateway` | Use one shared NAT instead of per-AZ | `false` | | `enable_flow_log` | Enable VPC flow logs | `true` | @@ -66,7 +66,7 @@ module "vpc" { ## Key outputs | Output | Description | -|--------|-------------| +| --- | --- | | `vpc_id` | The VPC ID | | `public_subnet_ids` | Public subnet IDs | | `private_subnet_ids` | Private (NAT-routed) subnet IDs | @@ -74,3 +74,147 @@ module "vpc" { | `firewall_subnet_ids` | Firewall subnet IDs | | `nat_public_ips` | NAT gateway Elastic IPs | | `flow_log_id` | VPC Flow Log ID | + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | 6.51.0 | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [flow\_log](#module\_flow\_log) | terraform-aws-modules/vpc/aws//modules/flow-log | 6.6.1 | +| [this](#module\_this) | ../tags | n/a | +| [vpc](#module\_vpc) | terraform-aws-modules/vpc/aws | 6.6.1 | +| [vpc\_endpoints](#module\_vpc\_endpoints) | terraform-aws-modules/vpc/aws//modules/vpc-endpoints | 6.6.1 | + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_internet_gateway.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/internet_gateway) | resource | +| [aws_route.firewall_to_igw](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route) | resource | +| [aws_route_table.edge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table) | resource | +| [aws_route_table.firewall](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table) | resource | +| [aws_route_table_association.edge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource | +| [aws_route_table_association.firewall](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association) | resource | +| [aws_subnet.firewall](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet) | resource | +| [aws_availability_zones.available](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/availability_zones) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [additional\_tag\_map](#input\_additional\_tag\_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | +| [attributes](#input\_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | +| [cloudwatch\_log\_group\_tags](#input\_cloudwatch\_log\_group\_tags) | Additional tags for the CloudWatch log group. | `map(string)` | `{}` | no | +| [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
}
| no | +| [create\_vpc\_endpoints](#input\_create\_vpc\_endpoints) | Whether to create VPC endpoints. | `bool` | `true` | no | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | +| [data\_type](#input\_data\_type) | The tag data\_type | `string` | `"None"` | no | +| [delimiter](#input\_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | +| [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | +| [dhcp\_options\_domain\_name](#input\_dhcp\_options\_domain\_name) | The suffix domain name to use by default when resolving non-FQDNs. | `string` | `""` | no | +| [dhcp\_options\_domain\_name\_servers](#input\_dhcp\_options\_domain\_name\_servers) | List of DNS server addresses for the DHCP option set. Use ['AmazonProvidedDNS'] for the default VPC resolver, or Route 53 Resolver inbound endpoint IPs. | `list(string)` |
[
"AmazonProvidedDNS"
]
| no | +| [dhcp\_options\_ntp\_servers](#input\_dhcp\_options\_ntp\_servers) | List of NTP servers for the DHCP option set. | `list(string)` | `[]` | no | +| [dhcp\_options\_tags](#input\_dhcp\_options\_tags) | Additional tags for the DHCP option set. | `map(string)` | `{}` | no | +| [enable\_dhcp\_options](#input\_enable\_dhcp\_options) | Create a custom DHCP option set and associate it with the VPC. | `bool` | `false` | no | +| [enable\_dns\_hostnames](#input\_enable\_dns\_hostnames) | Enable DNS hostnames in the VPC. | `bool` | `true` | no | +| [enable\_dns\_support](#input\_enable\_dns\_support) | Enable DNS support in the VPC. | `bool` | `true` | no | +| [enable\_flow\_log](#input\_enable\_flow\_log) | Enable VPC flow logs to CloudWatch Logs. | `bool` | `true` | no | +| [enable\_network\_firewall](#input\_enable\_network\_firewall) | When true, the VPC module creates firewall subnets, takes over
IGW management from the community module, and reconfigures
routing for AWS Network Firewall inspection:
- Firewall subnets created as standalone resources
- IGW created as a standalone resource (community module's create\_igw = false)
- Firewall subnets get a default route (0.0.0.0/0) to the IGW
- Public subnet default route is NOT created (callers must
inject 0.0.0.0/0 → firewall VPCE at the stack level)
When false (default), no firewall subnets are created, the
community module creates the IGW and public → IGW route as
normal — no Network Firewall in the path. | `bool` | `false` | no | +| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | +| [firewall\_subnet\_prefix](#input\_firewall\_subnet\_prefix) | Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). | `number` | `28` | no | +| [firewall\_subnet\_tags](#input\_firewall\_subnet\_tags) | Additional tags for the firewall subnets. | `map(string)` | `{}` | no | +| [firewall\_subnets](#input\_firewall\_subnets) | Explicit CIDR blocks for firewall subnets (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | no | +| [flow\_log\_kms\_key\_id](#input\_flow\_log\_kms\_key\_id) | ARN of a KMS key to encrypt the CloudWatch log group. Leave null for no encryption. | `string` | `null` | no | +| [flow\_log\_max\_aggregation\_interval](#input\_flow\_log\_max\_aggregation\_interval) | The maximum interval of time (seconds) during which a flow of packets is captured. Valid values: 60 (1 min) or 600 (10 min). | `number` | `600` | no | +| [flow\_log\_retention\_in\_days](#input\_flow\_log\_retention\_in\_days) | Number of days to retain VPC flow logs in CloudWatch. | `number` | `365` | no | +| [flow\_log\_tags](#input\_flow\_log\_tags) | Additional tags for the VPC flow log. | `map(string)` | `{}` | no | +| [flow\_log\_traffic\_type](#input\_flow\_log\_traffic\_type) | The type of traffic to capture. Valid values: ACCEPT, REJECT, ALL. | `string` | `"ALL"` | no | +| [iam\_role\_tags](#input\_iam\_role\_tags) | Additional tags for the IAM role used by the VPC flow log. | `map(string)` | `{}` | no | +| [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | +| [intra\_subnet\_prefix](#input\_intra\_subnet\_prefix) | Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each). | `number` | `23` | no | +| [intra\_subnet\_tags](#input\_intra\_subnet\_tags) | Additional tags for the intra (no-internet) subnets. | `map(string)` | `{}` | no | +| [intra\_subnets](#input\_intra\_subnets) | Explicit CIDR blocks for intra subnets with no internet route (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | no | +| [label\_key\_case](#input\_label\_key\_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | +| [label\_order](#input\_label\_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | +| [label\_value\_case](#input\_label\_value\_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no | +| [labels\_as\_tags](#input\_labels\_as\_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` |
[
"default"
]
| no | +| [manage\_default\_network\_acl](#input\_manage\_default\_network\_acl) | Adopt and manage the default network ACL. | `bool` | `true` | no | +| [manage\_default\_security\_group](#input\_manage\_default\_security\_group) | Adopt and manage the default security group, removing all inline rules. | `bool` | `true` | no | +| [map\_public\_ip\_on\_launch](#input\_map\_public\_ip\_on\_launch) | Auto-assign public IPs to instances launched in public subnets. | `bool` | `false` | no | +| [name](#input\_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | +| [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no | +| [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | +| [private\_subnet\_prefix](#input\_private\_subnet\_prefix) | Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each). | `number` | `23` | no | +| [private\_subnet\_tags](#input\_private\_subnet\_tags) | Additional tags for the private (NAT-routed) subnets. | `map(string)` | `{}` | no | +| [private\_subnets](#input\_private\_subnets) | Explicit CIDR blocks for private subnets with NAT (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | no | +| [project](#input\_project) | ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api` | `string` | `null` | no | +| [public\_facing](#input\_public\_facing) | Whether this resource is public facing | `bool` | `false` | no | +| [public\_subnet\_prefix](#input\_public\_subnet\_prefix) | Prefix length for public subnets (e.g. 24 = /24, 256 IPs each). | `number` | `24` | no | +| [public\_subnet\_tags](#input\_public\_subnet\_tags) | Additional tags for the public subnets. | `map(string)` | `{}` | no | +| [public\_subnets](#input\_public\_subnets) | Explicit CIDR blocks for public subnets (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | no | +| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | +| [region](#input\_region) | ID element \_(Rarely used, not included by default)\_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region | `string` | `null` | no | +| [service](#input\_service) | ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique | `string` | `null` | no | +| [service\_category](#input\_service\_category) | The tag service\_category | `string` | `"n/a"` | no | +| [single\_nat\_gateway](#input\_single\_nat\_gateway) | Provision a single shared NAT Gateway instead of one per AZ. Saves cost but reduces availability. | `bool` | `false` | no | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | +| [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | +| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [vpc\_cidr](#input\_vpc\_cidr) | The IPv4 CIDR block for the VPC. Works with any prefix length – subnet sizes are controlled by the *\_subnet\_prefix variables. | `string` | n/a | yes | +| [vpc\_endpoints](#input\_vpc\_endpoints) | Map of VPC endpoints to create. Each key is a logical name,
each value is passed through to the upstream vpc-endpoints
submodule.

Interface endpoints are placed in intra subnets by default.
Security groups must be created at the stack level and passed
per-endpoint via `security_group_ids`.

Gateway endpoints require `service_type = "Gateway"` and
`route_table_ids`.

Supported per-endpoint attributes:
service - AWS service name (e.g. "s3", "ecr.api")
service\_type - "Interface" (default) or "Gateway"
policy - JSON endpoint policy document
subnet\_ids - Override default intra subnets
security\_group\_ids - Security group IDs for this endpoint
private\_dns\_enabled - Enable private DNS (Interface only)
route\_table\_ids - Route table IDs (Gateway only)
tags - Per-endpoint tags | `any` | `{}` | no | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [azs](#output\_azs) | The availability zones used by this VPC. | +| [default\_security\_group\_id](#output\_default\_security\_group\_id) | The ID of the default security group. | +| [edge\_route\_table\_id](#output\_edge\_route\_table\_id) | ID of the IGW edge route table (only when enable\_network\_firewall = true). | +| [firewall\_route\_table\_ids](#output\_firewall\_route\_table\_ids) | List of IDs of the firewall route tables. | +| [firewall\_subnet\_ids](#output\_firewall\_subnet\_ids) | List of IDs of the firewall subnets. | +| [firewall\_subnets\_cidr\_blocks](#output\_firewall\_subnets\_cidr\_blocks) | List of CIDR blocks of the firewall subnets. | +| [flow\_log\_arn](#output\_flow\_log\_arn) | The ARN of the VPC Flow Log. | +| [flow\_log\_cloudwatch\_log\_group\_arn](#output\_flow\_log\_cloudwatch\_log\_group\_arn) | The ARN of the CloudWatch Log Group for VPC flow logs. | +| [flow\_log\_iam\_role\_arn](#output\_flow\_log\_iam\_role\_arn) | The ARN of the IAM role used by VPC flow logs. | +| [flow\_log\_id](#output\_flow\_log\_id) | The ID of the VPC Flow Log. | +| [igw\_arn](#output\_igw\_arn) | The ARN of the Internet Gateway. | +| [igw\_id](#output\_igw\_id) | The ID of the Internet Gateway. | +| [intra\_route\_table\_ids](#output\_intra\_route\_table\_ids) | List of IDs of the intra route tables. | +| [intra\_subnet\_ids](#output\_intra\_subnet\_ids) | List of IDs of the intra subnets (no internet route). | +| [intra\_subnets\_cidr\_blocks](#output\_intra\_subnets\_cidr\_blocks) | List of CIDR blocks of the intra subnets. | +| [nat\_gateway\_ids](#output\_nat\_gateway\_ids) | List of NAT Gateway IDs. | +| [nat\_public\_ips](#output\_nat\_public\_ips) | List of public Elastic IPs created for NAT Gateways. | +| [private\_route\_table\_ids](#output\_private\_route\_table\_ids) | List of IDs of the private route tables. | +| [private\_subnet\_ids](#output\_private\_subnet\_ids) | List of IDs of the private subnets (routed via NAT). | +| [private\_subnets\_cidr\_blocks](#output\_private\_subnets\_cidr\_blocks) | List of CIDR blocks of the private subnets. | +| [public\_route\_table\_ids](#output\_public\_route\_table\_ids) | List of IDs of the public route tables. | +| [public\_subnet\_ids](#output\_public\_subnet\_ids) | List of IDs of the public subnets. | +| [public\_subnets\_cidr\_blocks](#output\_public\_subnets\_cidr\_blocks) | List of CIDR blocks of the public subnets. | +| [vpc\_arn](#output\_vpc\_arn) | The ARN of the VPC. | +| [vpc\_cidr\_block](#output\_vpc\_cidr\_block) | The primary CIDR block of the VPC. | +| [vpc\_endpoints](#output\_vpc\_endpoints) | Map of VPC endpoints created, keyed by the logical name. | +| [vpc\_id](#output\_vpc\_id) | The ID of the VPC. | + + + From 38a2d459aeebcd2e4aee7103d6ff326150d4550c Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 19 Jun 2026 19:09:33 +0100 Subject: [PATCH 11/13] feat(vpc): enhance VPC module documentation and validation for subnet prefixes --- infrastructure/modules/vpc/README.md | 33 ++++++++++++++------- infrastructure/modules/vpc/data.tf | 3 ++ infrastructure/modules/vpc/locals.tf | 17 ++++++----- infrastructure/modules/vpc/main.tf | 22 ++++++++++++++ infrastructure/modules/vpc/variables.tf | 39 +++++++++++++++++++++---- 5 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 infrastructure/modules/vpc/data.tf diff --git a/infrastructure/modules/vpc/README.md b/infrastructure/modules/vpc/README.md index 03cb27f9..9c04e530 100644 --- a/infrastructure/modules/vpc/README.md +++ b/infrastructure/modules/vpc/README.md @@ -15,16 +15,27 @@ Consumers must review and update module calls before upgrading, including: Treat adoption of this module as a migration, not a drop-in swap. -## subnet tiers +## Subnet tiers | Tier | Prefix | Purpose | | --- | --- | --- | | Firewall | /28 | Network Firewall endpoints | | Public | /24 | Public-facing resources, NAT gateways | -| Private | /23 | Private workloads with internet access via NAT | -| Isolated | /23 | Fully isolated, no internet route | +| Private | /23 | Private workloads with internet access via NAT Gateway | +| Intra | /23 | Intra, no internet route via NAT Gateway | -subnet CIDRs are auto-calculated from the VPC CIDR (assumes a /16) across all available AZs in the region. Explicit overrides are available via `firewall_subnets`, `public_subnets`, `private_subnets`, and `isolated_subnets` variables. +Subnet CIDRs are auto-calculated from the VPC CIDR across all available AZs in the region. The auto-calculation works with **any VPC prefix length** between /16 and /28 (AWS limits). Explicit overrides are available via `firewall_subnets`, `public_subnets`, `private_subnets`, and `intra_subnets` variables. + +**Auto-calculation logic:** The module uses Terraform's `cidrsubnets()` function to carve non-overlapping subnets from the VPC CIDR, sizing each tier per the `*_subnet_prefix` variables. For example: + +- VPC CIDR `/20` with `firewall_subnet_prefix = 28` → /28 subnets (8 extra bits carved out) +- VPC CIDR `/16` with `public_subnet_prefix = 24` → /24 subnets (8 extra bits carved out) + +**AWS sizing constraints** (automatically validated): + +- VPC CIDR block: `/16` to `/28` netmask +- Subnet CIDR block: `/16` to `/28` netmask +- Subnet prefix must be larger (numerically) than VPC prefix (so subnets can be carved from the VPC) ## Features @@ -55,7 +66,7 @@ module "vpc" { | Variable | Description | Default | | --- | --- | --- | -| `vpc_cidr` | VPC CIDR block (/16 for auto-calculation) | `10.0.0.0/16` | +| `vpc_cidr` | VPC CIDR block (/16 to /28 per AWS limits); auto-calculation works with any prefix length | `10.0.0.0/16` | | `single_nat_gateway` | Use one shared NAT instead of per-AZ | `false` | | `enable_flow_log` | Enable VPC flow logs | `true` | | `flow_log_retention_in_days` | CloudWatch log retention | `365` | @@ -70,7 +81,7 @@ module "vpc" { | `vpc_id` | The VPC ID | | `public_subnet_ids` | Public subnet IDs | | `private_subnet_ids` | Private (NAT-routed) subnet IDs | -| `isolated_subnet_ids` | Isolated (no internet) subnet IDs | +| `intra_subnet_ids` | Intra (no internet) subnet IDs | | `firewall_subnet_ids` | Firewall subnet IDs | | `nat_public_ips` | NAT gateway Elastic IPs | | `flow_log_id` | VPC Flow Log ID | @@ -139,7 +150,7 @@ module "vpc" { | [enable\_network\_firewall](#input\_enable\_network\_firewall) | When true, the VPC module creates firewall subnets, takes over
IGW management from the community module, and reconfigures
routing for AWS Network Firewall inspection:
- Firewall subnets created as standalone resources
- IGW created as a standalone resource (community module's create\_igw = false)
- Firewall subnets get a default route (0.0.0.0/0) to the IGW
- Public subnet default route is NOT created (callers must
inject 0.0.0.0/0 → firewall VPCE at the stack level)
When false (default), no firewall subnets are created, the
community module creates the IGW and public → IGW route as
normal — no Network Firewall in the path. | `bool` | `false` | no | | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | | [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | -| [firewall\_subnet\_prefix](#input\_firewall\_subnet\_prefix) | Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). | `number` | `28` | no | +| [firewall\_subnet\_prefix](#input\_firewall\_subnet\_prefix) | Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc\_cidr prefix. It is highly recommended to use /28 for firewall subnets to minimize wasted IPs. | `number` | `28` | no | | [firewall\_subnet\_tags](#input\_firewall\_subnet\_tags) | Additional tags for the firewall subnets. | `map(string)` | `{}` | no | | [firewall\_subnets](#input\_firewall\_subnets) | Explicit CIDR blocks for firewall subnets (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | no | | [flow\_log\_kms\_key\_id](#input\_flow\_log\_kms\_key\_id) | ARN of a KMS key to encrypt the CloudWatch log group. Leave null for no encryption. | `string` | `null` | no | @@ -149,7 +160,7 @@ module "vpc" { | [flow\_log\_traffic\_type](#input\_flow\_log\_traffic\_type) | The type of traffic to capture. Valid values: ACCEPT, REJECT, ALL. | `string` | `"ALL"` | no | | [iam\_role\_tags](#input\_iam\_role\_tags) | Additional tags for the IAM role used by the VPC flow log. | `map(string)` | `{}` | no | | [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | -| [intra\_subnet\_prefix](#input\_intra\_subnet\_prefix) | Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each). | `number` | `23` | no | +| [intra\_subnet\_prefix](#input\_intra\_subnet\_prefix) | Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc\_cidr prefix. | `number` | `23` | no | | [intra\_subnet\_tags](#input\_intra\_subnet\_tags) | Additional tags for the intra (no-internet) subnets. | `map(string)` | `{}` | no | | [intra\_subnets](#input\_intra\_subnets) | Explicit CIDR blocks for intra subnets with no internet route (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | no | | [label\_key\_case](#input\_label\_key\_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | @@ -162,12 +173,12 @@ module "vpc" { | [name](#input\_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | | [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no | | [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | -| [private\_subnet\_prefix](#input\_private\_subnet\_prefix) | Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each). | `number` | `23` | no | +| [private\_subnet\_prefix](#input\_private\_subnet\_prefix) | Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc\_cidr prefix. | `number` | `23` | no | | [private\_subnet\_tags](#input\_private\_subnet\_tags) | Additional tags for the private (NAT-routed) subnets. | `map(string)` | `{}` | no | | [private\_subnets](#input\_private\_subnets) | Explicit CIDR blocks for private subnets with NAT (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | no | | [project](#input\_project) | ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api` | `string` | `null` | no | | [public\_facing](#input\_public\_facing) | Whether this resource is public facing | `bool` | `false` | no | -| [public\_subnet\_prefix](#input\_public\_subnet\_prefix) | Prefix length for public subnets (e.g. 24 = /24, 256 IPs each). | `number` | `24` | no | +| [public\_subnet\_prefix](#input\_public\_subnet\_prefix) | Prefix length for public subnets (e.g. 24 = /24, 256 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc\_cidr prefix. | `number` | `24` | no | | [public\_subnet\_tags](#input\_public\_subnet\_tags) | Additional tags for the public subnets. | `map(string)` | `{}` | no | | [public\_subnets](#input\_public\_subnets) | Explicit CIDR blocks for public subnets (one per AZ). Leave empty to auto-calculate. | `list(string)` | `[]` | no | | [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | @@ -180,7 +191,7 @@ module "vpc" { | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | | [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | -| [vpc\_cidr](#input\_vpc\_cidr) | The IPv4 CIDR block for the VPC. Works with any prefix length – subnet sizes are controlled by the *\_subnet\_prefix variables. | `string` | n/a | yes | +| [vpc\_cidr](#input\_vpc\_cidr) | The IPv4 CIDR block for the VPC (AWS allows /16 to /28 netmask). Subnet CIDR blocks are auto-calculated from this VPC CIDR using the *\_subnet\_prefix variables. | `string` | n/a | yes | | [vpc\_endpoints](#input\_vpc\_endpoints) | Map of VPC endpoints to create. Each key is a logical name,
each value is passed through to the upstream vpc-endpoints
submodule.

Interface endpoints are placed in intra subnets by default.
Security groups must be created at the stack level and passed
per-endpoint via `security_group_ids`.

Gateway endpoints require `service_type = "Gateway"` and
`route_table_ids`.

Supported per-endpoint attributes:
service - AWS service name (e.g. "s3", "ecr.api")
service\_type - "Interface" (default) or "Gateway"
policy - JSON endpoint policy document
subnet\_ids - Override default intra subnets
security\_group\_ids - Security group IDs for this endpoint
private\_dns\_enabled - Enable private DNS (Interface only)
route\_table\_ids - Route table IDs (Gateway only)
tags - Per-endpoint tags | `any` | `{}` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | diff --git a/infrastructure/modules/vpc/data.tf b/infrastructure/modules/vpc/data.tf new file mode 100644 index 00000000..87d8f482 --- /dev/null +++ b/infrastructure/modules/vpc/data.tf @@ -0,0 +1,3 @@ +data "aws_availability_zones" "available" { + state = "available" +} diff --git a/infrastructure/modules/vpc/locals.tf b/infrastructure/modules/vpc/locals.tf index 4da4b9ec..f65cff2d 100644 --- a/infrastructure/modules/vpc/locals.tf +++ b/infrastructure/modules/vpc/locals.tf @@ -1,22 +1,23 @@ -data "aws_availability_zones" "available" { - state = "available" -} - locals { azs = data.aws_availability_zones.available.names az_count = length(local.azs) + # ───────────────────────────────────────────────────────────── + # VPC CIDR prefix validation + # + # Extract the VPC prefix and validate cross-variable constraints. + # ───────────────────────────────────────────────────────────── + vpc_prefix_length = tonumber(split("/", var.vpc_cidr)[1]) + # ───────────────────────────────────────────────────────────── # Subnet CIDR allocation # # Uses cidrsubnets() to carve non-overlapping ranges from the - # VPC CIDR regardless of its prefix length. The target subnet - # sizes are controlled by var.firewall_subnet_prefix, etc. + # VPC CIDR. The target subnet sizes are controlled by + # var.firewall_subnet_prefix, etc. # # newbits = target_prefix - vpc_prefix # ───────────────────────────────────────────────────────────── - vpc_prefix_length = tonumber(split("/", var.vpc_cidr)[1]) - firewall_newbits = var.firewall_subnet_prefix - local.vpc_prefix_length public_newbits = var.public_subnet_prefix - local.vpc_prefix_length private_newbits = var.private_subnet_prefix - local.vpc_prefix_length diff --git a/infrastructure/modules/vpc/main.tf b/infrastructure/modules/vpc/main.tf index 1793c7a2..d4dce53c 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -73,6 +73,28 @@ module "vpc" { tags = { for k, v in module.this.tags : k => v if k != "Name" } } +check "subnet_prefix_vs_vpc_prefix" { + assert { + condition = var.firewall_subnet_prefix > local.vpc_prefix_length + error_message = "firewall_subnet_prefix (/${var.firewall_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})." + } + + assert { + condition = var.public_subnet_prefix > local.vpc_prefix_length + error_message = "public_subnet_prefix (/${var.public_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})." + } + + assert { + condition = var.private_subnet_prefix > local.vpc_prefix_length + error_message = "private_subnet_prefix (/${var.private_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})." + } + + assert { + condition = var.intra_subnet_prefix > local.vpc_prefix_length + error_message = "intra_subnet_prefix (/${var.intra_subnet_prefix}) must be more specific than the VPC CIDR (prefix length must be greater than (/${local.vpc_prefix_length})." + } +} + ################################################################ # Firewall subnets # diff --git a/infrastructure/modules/vpc/variables.tf b/infrastructure/modules/vpc/variables.tf index 950abeaa..51be92a0 100644 --- a/infrastructure/modules/vpc/variables.tf +++ b/infrastructure/modules/vpc/variables.tf @@ -23,13 +23,22 @@ variable "enable_network_firewall" { } variable "vpc_cidr" { - description = "The IPv4 CIDR block for the VPC. Works with any prefix length – subnet sizes are controlled by the *_subnet_prefix variables." + description = "The IPv4 CIDR block for the VPC (AWS allows /16 to /28 netmask). Subnet CIDR blocks are auto-calculated from this VPC CIDR using the *_subnet_prefix variables." type = string validation { condition = can(cidrhost(var.vpc_cidr, 0)) error_message = "vpc_cidr must be a valid CIDR block." } + + validation { + condition = ( + can(tonumber(split("/", var.vpc_cidr)[1])) && + tonumber(split("/", var.vpc_cidr)[1]) >= 16 && + tonumber(split("/", var.vpc_cidr)[1]) <= 28 + ) + error_message = "VPC CIDR prefix must be between /16 (65,536 IPs) and /28 (16 IPs) per AWS limits. Whilst technically /28 is allowed, it is too small to support the module's multiple subnets and AWS reserved IPs." + } } ################################################################ @@ -40,27 +49,47 @@ variable "vpc_cidr" { ################################################################ variable "firewall_subnet_prefix" { - description = "Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each)." + description = "Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix. It is highly recommended to use /28 for firewall subnets to minimize wasted IPs." type = number default = 28 + + validation { + condition = length(var.firewall_subnets) == 0 ? (var.firewall_subnet_prefix >= 16 && var.firewall_subnet_prefix <= 28) : true + error_message = "Subnet prefix must be between /16 and /28 per AWS limits." + } } variable "public_subnet_prefix" { - description = "Prefix length for public subnets (e.g. 24 = /24, 256 IPs each)." + description = "Prefix length for public subnets (e.g. 24 = /24, 256 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix." type = number default = 24 + + validation { + condition = length(var.public_subnets) == 0 ? (var.public_subnet_prefix >= 16 && var.public_subnet_prefix <= 28) : true + error_message = "Subnet prefix must be between /16 and /28 per AWS limits." + } } variable "private_subnet_prefix" { - description = "Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each)." + description = "Prefix length for private subnets with NAT (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix." type = number default = 23 + + validation { + condition = length(var.private_subnets) == 0 ? (var.private_subnet_prefix >= 16 && var.private_subnet_prefix <= 28) : true + error_message = "Subnet prefix must be between /16 and /28 per AWS limits." + } } variable "intra_subnet_prefix" { - description = "Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each)." + description = "Prefix length for intra subnets with no internet route (e.g. 23 = /23, 512 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix." type = number default = 23 + + validation { + condition = length(var.intra_subnets) == 0 ? (var.intra_subnet_prefix >= 16 && var.intra_subnet_prefix <= 28) : true + error_message = "Subnet prefix must be between /16 and /28 per AWS limits." + } } ################################################################ From c505715cfdc499071348edadcbd9eac820e86084 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 19 Jun 2026 19:19:40 +0100 Subject: [PATCH 12/13] feat(vpc): add availability zones variable and update subnet calculation logic --- infrastructure/modules/vpc/README.md | 9 ++++++--- infrastructure/modules/vpc/locals.tf | 2 +- infrastructure/modules/vpc/variables.tf | 6 ++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/infrastructure/modules/vpc/README.md b/infrastructure/modules/vpc/README.md index 9c04e530..2e290a8c 100644 --- a/infrastructure/modules/vpc/README.md +++ b/infrastructure/modules/vpc/README.md @@ -24,7 +24,7 @@ Treat adoption of this module as a migration, not a drop-in swap. | Private | /23 | Private workloads with internet access via NAT Gateway | | Intra | /23 | Intra, no internet route via NAT Gateway | -Subnet CIDRs are auto-calculated from the VPC CIDR across all available AZs in the region. The auto-calculation works with **any VPC prefix length** between /16 and /28 (AWS limits). Explicit overrides are available via `firewall_subnets`, `public_subnets`, `private_subnets`, and `intra_subnets` variables. +Subnet CIDRs are auto-calculated from the VPC CIDR across the first three available AZs in the region by default. Set `availability_zones` to pin a specific AZ list or to use a different AZ count. Explicit CIDR overrides are available via `firewall_subnets`, `public_subnets`, `private_subnets`, and `intra_subnets`. **Auto-calculation logic:** The module uses Terraform's `cidrsubnets()` function to carve non-overlapping subnets from the VPC CIDR, sizing each tier per the `*_subnet_prefix` variables. For example: @@ -36,6 +36,7 @@ Subnet CIDRs are auto-calculated from the VPC CIDR across all available AZs in t - VPC CIDR block: `/16` to `/28` netmask - Subnet CIDR block: `/16` to `/28` netmask - Subnet prefix must be larger (numerically) than VPC prefix (so subnets can be carved from the VPC) +- Smaller VPC CIDRs may require larger subnet prefixes or explicit subnet overrides when the requested subnet count cannot fit inside the CIDR range ## Features @@ -66,12 +67,13 @@ module "vpc" { | Variable | Description | Default | | --- | --- | --- | -| `vpc_cidr` | VPC CIDR block (/16 to /28 per AWS limits); auto-calculation works with any prefix length | `10.0.0.0/16` | +| `vpc_cidr` | VPC CIDR block (/16 to /28 per AWS limits) | Required | +| `availability_zones` | Explicit AZs for subnet placement; defaults to the first three available AZs | `null` | | `single_nat_gateway` | Use one shared NAT instead of per-AZ | `false` | | `enable_flow_log` | Enable VPC flow logs | `true` | | `flow_log_retention_in_days` | CloudWatch log retention | `365` | | `flow_log_traffic_type` | ACCEPT, REJECT, or ALL | `ALL` | -| `flow_log_kms_key_id` | KMS key arn for log encryption | `null` | +| `flow_log_kms_key_id` | KMS key ARN for log encryption | `null` | | `map_public_ip_on_launch` | Auto-assign public IPs in public subnets | `false` | ## Key outputs @@ -131,6 +133,7 @@ module "vpc" { | [additional\_tag\_map](#input\_additional\_tag\_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no | | [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | | [attributes](#input\_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | +| [availability\_zones](#input\_availability\_zones) | Availability zones to use for the VPC. Leave null to use the first three available AZs in the current region. | `list(string)` | `null` | no | | [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | | [cloudwatch\_log\_group\_tags](#input\_cloudwatch\_log\_group\_tags) | Additional tags for the CloudWatch log group. | `map(string)` | `{}` | no | | [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
}
| no | diff --git a/infrastructure/modules/vpc/locals.tf b/infrastructure/modules/vpc/locals.tf index f65cff2d..41282ef8 100644 --- a/infrastructure/modules/vpc/locals.tf +++ b/infrastructure/modules/vpc/locals.tf @@ -1,5 +1,5 @@ locals { - azs = data.aws_availability_zones.available.names + azs = length(coalesce(var.availability_zones, [])) > 0 ? var.availability_zones : slice(data.aws_availability_zones.available.names, 0, 3) az_count = length(local.azs) # ───────────────────────────────────────────────────────────── diff --git a/infrastructure/modules/vpc/variables.tf b/infrastructure/modules/vpc/variables.tf index 51be92a0..1134e77d 100644 --- a/infrastructure/modules/vpc/variables.tf +++ b/infrastructure/modules/vpc/variables.tf @@ -48,6 +48,12 @@ variable "vpc_cidr" { # cidrsubnets() to carve non-overlapping ranges automatically. ################################################################ +variable "availability_zones" { + description = "Availability zones to use for the VPC. Leave null to use the first three available AZs in the current region." + type = list(string) + default = null +} + variable "firewall_subnet_prefix" { description = "Prefix length for firewall subnets (e.g. 28 = /28, 16 IPs each). AWS allows /16 to /28; must be larger (numerically) than vpc_cidr prefix. It is highly recommended to use /28 for firewall subnets to minimize wasted IPs." type = number From b08090154e5918e079c8516510baf0ae83cd6f2d Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 08:55:05 +0100 Subject: [PATCH 13/13] feat(gitleaks): enhance gitleaks configuration for IPv4 and IPv6 rules --- .gitleaksignore | 4 ++++ scripts/config/gitleaks.toml | 27 +++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index f97f5c8a..bf9d628a 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -5,3 +5,7 @@ e876843351a025eb754ec61982c8b7d95deeb709:.pre-commit-config.yaml:ipv4:119 e364bc1869c67729653c7efb4d6169f2294e68de:.pre-commit-config.yaml:ipv4:110 62088509f98ce02ce379adef2168b867eecfb5da:.pre-commit-config.yaml:ipv4:110 a3fa25da4e8f9eaa2e28c29f6196f23bfe87a58d:.pre-commit-config.yaml:ipv4:119 +# Historical false positive: example ARN comment in tags/main.tf contained hex-like content +# which triggered the ipv6 rule. Comment updated in later commit; old commits suppressed here. +7b49758d98757e8f404cb2c540c1f146afd6e395:infrastructure/modules/tags/main.tf:ipv6:131 +091dcd76884ffd307aee6c6b306b015c065f4896:infrastructure/modules/tags/main.tf:ipv6:131 diff --git a/scripts/config/gitleaks.toml b/scripts/config/gitleaks.toml index 61b22278..99dbffa5 100644 --- a/scripts/config/gitleaks.toml +++ b/scripts/config/gitleaks.toml @@ -11,8 +11,31 @@ regex = '''[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}''' [rules.allowlist] regexTarget = "match" regexes = [ - # Exclude the private network IPv4 addresses as well as the DNS servers for Google and OpenDNS - '''(127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|0\.0\.0\.0|255\.255\.255\.255|8\.8\.8\.8|8\.8\.4\.4|208\.67\.222\.222|208\.67\.220\.220)''', + # Exclude private/reserved IPv4 addresses and well-known DNS servers used in docs/examples. + # Includes RFC5737 TEST-NET ranges: 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 + '''(127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|192\.0\.2\.[0-9]{1,3}|198\.51\.100\.[0-9]{1,3}|203\.0\.113\.[0-9]{1,3}|0\.0\.0\.0|255\.255\.255\.255|8\.8\.8\.8|8\.8\.4\.4|1\.1\.1\.1|1\.0\.0\.1)''', +] + +[[rules]] +description = "IPv6" +id = "ipv6" +# Matches valid IPv6 forms requiring at least 2 groups on each side of :: to +# avoid false positives from AWS ARNs (which use :: between region and account). +# full: 2001:0db8:85a3:0000:0000:8a2e:0370:7334 +# compressed: 2001:db8::1, fe80:db8::1 +# trailing :: fe80:db8:: (2+ groups required before ::) +# leading :: ::db8:1 (2+ groups required after ::) +# Note: RE2 does not support lookahead/lookbehind so boundary enforcement is +# achieved structurally via minimum repetition counts. +regex = '''(?i)(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:){2,7}:|(?:[0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:){1,5}(?::[0-9a-f]{1,4}){1,2}|(?:[0-9a-f]{1,4}:){1,4}(?::[0-9a-f]{1,4}){1,3}|(?:[0-9a-f]{1,4}:){1,3}(?::[0-9a-f]{1,4}){1,4}|(?:[0-9a-f]{1,4}:){1,2}(?::[0-9a-f]{1,4}){1,5}|[0-9a-f]{1,4}:(?::[0-9a-f]{1,4}){1,6}|:(?::[0-9a-f]{1,4}){2,7}''' + +[rules.allowlist] +regexTarget = "match" +regexes = [ + # Exclude IPv6 documentation prefixes used in examples. + # RFC3849: 2001:db8::/32 + # RFC9637: 3fff::/20 (3fff:0000:: to 3fff:0fff::) + '''(?i)(^|[^0-9a-f])(2001:db8:|3fff:0[0-9a-f]{0,3}:)''', ] [allowlist]