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 new file mode 100644 index 00000000..2e290a8c --- /dev/null +++ b/infrastructure/modules/vpc/README.md @@ -0,0 +1,234 @@ +# VPC + +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 | +| --- | --- | --- | +| Firewall | /28 | Network Firewall endpoints | +| Public | /24 | Public-facing resources, NAT gateways | +| 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 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: + +- 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) +- Smaller VPC CIDRs may require larger subnet prefixes or explicit subnet overrides when the requested subnet count cannot fit inside the CIDR range + +## Features + +- **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) + +## Usage + +```terraform +module "vpc" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/vpc?ref=" + + environment = "dev" + service = "bcss" + name = "vpc" + + vpc_cidr = "10.0.0.0/16" + single_nat_gateway = true # cost saving for non-prod + + flow_log_kms_key_id = aws_kms_key.cloudwatch.arn # optional encryption +} +``` + +## Key variables + +| Variable | Description | Default | +| --- | --- | --- | +| `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` | +| `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 | +| `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 | + + + + +## 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 | +| [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 | +| [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). 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 | +| [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). 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 | +| [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). 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). 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 | +| [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 (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 | + +## 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. | + + + diff --git a/infrastructure/modules/vpc/context.tf b/infrastructure/modules/vpc/context.tf new file mode 100644 index 00000000..b644ebfb --- /dev/null +++ b/infrastructure/modules/vpc/context.tf @@ -0,0 +1,377 @@ +# 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 +# +# +# 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 = "../tags" + + enabled = var.enabled + 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/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 new file mode 100644 index 00000000..41282ef8 --- /dev/null +++ b/infrastructure/modules/vpc/locals.tf @@ -0,0 +1,46 @@ +locals { + azs = length(coalesce(var.availability_zones, [])) > 0 ? var.availability_zones : slice(data.aws_availability_zones.available.names, 0, 3) + 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. The target subnet sizes are controlled by + # var.firewall_subnet_prefix, etc. + # + # newbits = target_prefix - vpc_prefix + # ───────────────────────────────────────────────────────────── + 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 + 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 588423c8..d4dce53c 100644 --- a/infrastructure/modules/vpc/main.tf +++ b/infrastructure/modules/vpc/main.tf @@ -1,220 +1,276 @@ -# 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 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. +################################################################ -# 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.intra_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" - } -} + # 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 -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" - } -} + # 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 -# 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 - } -} + # NAT gateway configuration + enable_nat_gateway = true + single_nat_gateway = var.single_nat_gateway + one_nat_gateway_per_az = !var.single_nat_gateway -# 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 + # DNS + enable_dns_hostnames = var.enable_dns_hostnames + enable_dns_support = var.enable_dns_support - route { - cidr_block = "0.0.0.0/0" - gateway_id = aws_internet_gateway.igw.id - } - tags = { - Name = var.name_prefix - } -} + # 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 -# 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 - } -} + # Public subnets + map_public_ip_on_launch = var.map_public_ip_on_launch -resource "aws_eip" "eip_a" { - tags = { - Name = var.name_prefix - } + # Security defaults + manage_default_security_group = var.manage_default_security_group + default_security_group_ingress = [] + default_security_group_egress = [] + + manage_default_network_acl = var.manage_default_network_acl + manage_default_route_table = true + + # Subnet tags + public_subnet_tags = var.public_subnet_tags + private_subnet_tags = var.private_subnet_tags + intra_subnet_tags = var.intra_subnet_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" } } -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 +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})." } -} -resource "aws_eip" "eip_b" { - tags = { - Name = var.name_prefix + 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})." } -} -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 + 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})." } -} -resource "aws_eip" "eip_c" { - tags = { - Name = var.name_prefix + 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 +# +# Created as standalone resources because the upstream module +# does not have a dedicated firewall subnet tier. +################################################################ -# 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 +resource "aws_subnet" "firewall" { + count = module.this.enabled && var.enable_network_firewall ? local.az_count : 0 - route { - cidr_block = "0.0.0.0/0" - nat_gateway_id = aws_nat_gateway.nat_gw_a.id - } - 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" "private_rt_b" { - vpc_id = aws_vpc.vpc.id +resource "aws_route_table" "firewall" { + count = module.this.enabled && var.enable_network_firewall ? 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 - } + vpc_id = module.vpc.vpc_id + + tags = merge(module.this.tags, { + Name = "${module.this.id}-firewall-${local.azs[count.index]}" + }) } -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 - } +resource "aws_route_table_association" "firewall" { + 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 } -# 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 +################################################################ +# Internet Gateway (Network Firewall routing mode) +# +# 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) +# +# When enable_network_firewall = false, these resources are not +# created and the community module handles everything. +################################################################ + +resource "aws_internet_gateway" "this" { + count = module.this.enabled && var.enable_network_firewall ? 1 : 0 + + vpc_id = module.vpc.vpc_id + + tags = merge(module.this.tags, { + Name = module.this.id + }) } -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" "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_route_table_association" "private_rta_c" { - subnet_id = aws_subnet.private_subnet_c.id - route_table_id = aws_route_table.private_rt_c.id +################################################################ +# 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 + + vpc_id = module.vpc.vpc_id + + tags = merge(module.this.tags, { + Name = "${module.this.id}-edge" + }) } -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_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_route_table_association" "public_rta_b" { - subnet_id = aws_subnet.public_subnet_b.id - route_table_id = aws_route_table.public_rt.id +################################################################ +# 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_route_table_association" "public_rta_c" { - subnet_id = aws_subnet.public_subnet_c.id - route_table_id = aws_route_table.public_rt.id +################################################################ +# 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" + + create = module.this.enabled && var.create_vpc_endpoints + + 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 86e1412b..56f47a68 100644 --- a/infrastructure/modules/vpc/outputs.tf +++ b/infrastructure/modules/vpc/outputs.tf @@ -1,19 +1,182 @@ +################################################################ +# 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 +} + +################################################################ +# Intra subnets (no internet) +################################################################ + +output "intra_subnet_ids" { + description = "List of IDs of the intra subnets (no internet route)." + value = module.vpc.intra_subnets +} + +output "intra_subnets_cidr_blocks" { + description = "List of CIDR blocks of the intra subnets." + value = module.vpc.intra_subnets_cidr_blocks +} + +output "intra_route_table_ids" { + description = "List of IDs of the intra 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 = 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 +} + +################################################################ +# 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 = 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 = 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 = 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/readme.md b/infrastructure/modules/vpc/readme.md deleted file mode 100644 index bfb5faa2..00000000 --- a/infrastructure/modules/vpc/readme.md +++ /dev/null @@ -1,129 +0,0 @@ -# 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. - -## Preprequisites - -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 - -## Setup - -To use this module simply call it from your Terraform stack, here is an example Terraform file: - -```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 - -This is the name of the environment it is deployed into, this might be `CICD`, `NTF`, `UFT` or `Prod`. - -### 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 | - -## Inputs - -| 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 | - -## Outputs - -| 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 | - - - diff --git a/infrastructure/modules/vpc/variables.tf b/infrastructure/modules/vpc/variables.tf index dc480192..1134e77d 100644 --- a/infrastructure/modules/vpc/variables.tf +++ b/infrastructure/modules/vpc/variables.tf @@ -1,22 +1,343 @@ -# 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 "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 (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." + } +} + +################################################################ +# Subnet prefix lengths +# +# Control the size of each subnet tier. The module uses +# 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 + 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). 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). 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). 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." + } +} + +################################################################ +# Subnet CIDR overrides +# +# When left empty (default) the module auto-calculates CIDRs +# from var.vpc_cidr using the prefix lengths above. +################################################################ + +variable "firewall_subnets" { + description = "Explicit CIDR blocks for firewall subnets (one per AZ). Leave empty to auto-calculate." + type = list(string) + default = [] } -# tflint-ignore: terraform_unused_declarations -variable "name" { - description = "The name of the resource" +variable "public_subnets" { + description = "Explicit CIDR blocks for public subnets (one per AZ). Leave empty to auto-calculate." + type = list(string) + default = [] +} + +variable "private_subnets" { + description = "Explicit CIDR blocks for private subnets with NAT (one per AZ). Leave empty to auto-calculate." + type = list(string) + default = [] +} + +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 = [] +} + +################################################################ +# 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 +} + +################################################################ +# 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 "name_prefix" { - description = "the environment and project" +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 +################################################################ + +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 "intra_subnet_tags" { + description = "Additional tags for the intra (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 +} + +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 = {} } diff --git a/infrastructure/modules/vpc/versions.tf b/infrastructure/modules/vpc/versions.tf index b699c78b..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.47.0" + version = ">= 6.42" } } } diff --git a/scripts/config/gitleaks.toml b/scripts/config/gitleaks.toml index 8371dcbc..99dbffa5 100644 --- a/scripts/config/gitleaks.toml +++ b/scripts/config/gitleaks.toml @@ -39,4 +39,4 @@ regexes = [ ] [allowlist] -paths = ['''.terraform.lock.hcl''', '''poetry.lock''', '''yarn.lock'''] +paths = ['''.terraform.lock.hcl''', '''poetry.lock''', '''yarn.lock''', '''.pre-commit-config.yaml''']