diff --git a/infrastructure/modules/vpc/context.tf b/infrastructure/modules/vpc/context.tf new file mode 100644 index 0000000..cef7417 --- /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 0000000..34b6185 --- /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 9be66bd..41ae431 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -1,220 +1,165 @@ -# 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}" - } + vpc_id = module.vpc.vpc_id + cidr_block = local.firewall_subnets[count.index] + availability_zone = local.azs[count.index] + + tags = merge(module.this.tags, var.firewall_subnet_tags, { + Name = "${module.this.id}-firewall-${local.azs[count.index]}" + Type = "firewall" + }) } +resource "aws_route_table" "firewall" { + count = module.this.enabled ? local.az_count : 0 -# 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 + vpc_id = module.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}" - } + tags = merge(module.this.tags, { + Name = "${module.this.id}-firewall-${local.azs[count.index]}" + }) } -resource "aws_route_table" "private_rt_b" { - vpc_id = aws_vpc.vpc.id +resource "aws_route_table_association" "firewall" { + count = module.this.enabled ? local.az_count : 0 - 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}" - } + subnet_id = aws_subnet.firewall[count.index].id + route_table_id = aws_route_table.firewall[count.index].id } -# 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 -} +################################################################ +# 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_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_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 -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 + tags = module.this.tags } -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 +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_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_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_route_table_association" "public_rta_c" { - subnet_id = aws_subnet.public_subnet_c.id - route_table_id = aws_route_table.public_rt.id +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 86e1412..10e6452 100644 --- a/infrastructure/modules/vpc/outputs.tf +++ b/infrastructure/modules/vpc/outputs.tf @@ -1,19 +1,154 @@ +################################################################ +# 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 +} + +################################################################ +# 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/readme.md b/infrastructure/modules/vpc/readme.md index 97588dc..b32fd76 100644 --- a/infrastructure/modules/vpc/readme.md +++ b/infrastructure/modules/vpc/readme.md @@ -1,126 +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 - -No requirements. - -## Providers - -| Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.46.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 | `any` | n/a | yes | -| [name](#input\_name) | The name of the resource | `string` | `""` | no | -| [name\_prefix](#input\_name\_prefix) | the environment and project | `any` | n/a | yes | -| [vpc\_cidr\_prefix](#input\_vpc\_cidr\_prefix) | The CIDR block prefix for the VPC | `any` | 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/variables.tf b/infrastructure/modules/vpc/variables.tf index 462e540..05533e3 100644 --- a/infrastructure/modules/vpc/variables.tf +++ b/infrastructure/modules/vpc/variables.tf @@ -1,16 +1,161 @@ -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." + } +} + +################################################################ +# 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 "public_subnets" { + description = "Explicit /24 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." + 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 "name" { - description = "The name of the resource" - default = "" +variable "private_subnet_tags" { + description = "Additional tags for the private (NAT-routed) subnets." + type = map(string) + default = {} } -variable "name_prefix" { - description = "the environment and project" +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 = {} +} + +################################################################ +# 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 "vpc_cidr_prefix" { - description = "The CIDR block prefix for the VPC" +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 } diff --git a/infrastructure/modules/vpc/versions.tf b/infrastructure/modules/vpc/versions.tf new file mode 100644 index 0000000..ad55bb5 --- /dev/null +++ b/infrastructure/modules/vpc/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.7" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.28, < 7.0" + } + } +}