diff --git a/.github/settings.yml b/.github/settings.yml index 4043f57..e3a541c 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -1,11 +1,7 @@ # Upstream changes from _extends are only recognized when modifications are made to this file in the default branch. _extends: .github repository: - name: template - description: Template for Terraform Components + name: aws-tgw-spoke + description: This component is responsible for provisioning [AWS Transit Gateway](https://aws homepage: https://cloudposse.com/accelerate topics: terraform, terraform-component - - - - diff --git a/README.yaml b/README.yaml index 73d70c3..bdd3359 100644 --- a/README.yaml +++ b/README.yaml @@ -1,70 +1,204 @@ -name: "template" - +name: "aws-tgw-spoke" # Canonical GitHub repo -github_repo: "cloudposse-terraform-components/template" - +github_repo: "cloudposse-terraform-components/aws-tgw-spoke" # Short description of this project description: |- - Description of this component + This component is responsible for provisioning [AWS Transit Gateway](https://aws.amazon.com/transit-gateway) attachments + to connect VPCs in a `spoke` account to different accounts through a central `hub`. + + ## Usage + + **Stack Level**: Regional + + Here's an example snippet for how to configure and use this component: -usage: |- - **Stack Level**: Regional or Test47 + stacks/catalog/tgw/spoke.yaml - Here's an example snippet for how to use this component. - ```yaml components: terraform: - foo: + tgw/spoke-defaults: + metadata: + type: abstract + component: tgw/spoke vars: enabled: true + name: tgw-spoke + tags: + Team: sre + Service: tgw-spoke + expose_eks_sg: false + tgw_hub_tenant_name: core + tgw_hub_environment_name: ue1 + + tgw/spoke: + metadata: + inherits: + - tgw/spoke-defaults + vars: + # This is what THIS spoke is allowed to connect to. + # since this is deployed to each plat account (dev->prod), + # we allow connections to network and auto. + connections: + - account: + tenant: core + stage: network + # Set this value if the vpc component has a different name in this account + vpc_component_names: + - vpc-dev + - account: + tenant: core + stage: auto ``` -include: - - "docs/terraform.md" + stacks/ue2/dev.yaml -tags: - - terraform - - terraform-modules - - aws - - components - - terraform-components - - root - - geodesic - - reference-implementation - - reference-architecture + ```yaml + import: + - catalog/tgw/spoke + + components: + terraform: + tgw/spoke: + vars: + # use when there is not an EKS cluster in the stack + expose_eks_sg: false + # override default connections + connections: + - account: + tenant: core + stage: network + vpc_component_names: + - vpc-dev + - account: + tenant: core + stage: auto + - account: + tenant: plat + stage: dev + eks_component_names: + - eks/cluster + - account: + tenant: plat + stage: qa + eks_component_names: + - eks/cluster + ``` + + To provision the attachments for a spoke account: + + ```sh + atmos terraform plan tgw/spoke -s -- + atmos terraform apply tgw/spoke -s -- + ``` + + + + ## Requirements + | Name | Version | + |------|---------| + | [terraform](#requirement\_terraform) | >= 1.0.0 | + | [aws](#requirement\_aws) | >= 4.1 | + + ## Providers + + | Name | Version | + |------|---------| + | [aws](#provider\_aws) | >= 4.1 | + | [aws.tgw-hub](#provider\_aws.tgw-hub) | >= 4.1 | + + ## Modules + + | Name | Source | Version | + |------|--------|---------| + | [cross\_region\_hub\_connector](#module\_cross\_region\_hub\_connector) | cloudposse/stack-config/yaml//modules/remote-state | 1.5.0 | + | [iam\_roles](#module\_iam\_roles) | ../../account-map/modules/iam-roles | n/a | + | [tgw\_hub](#module\_tgw\_hub) | cloudposse/stack-config/yaml//modules/remote-state | 1.5.0 | + | [tgw\_hub\_role](#module\_tgw\_hub\_role) | ../../account-map/modules/iam-roles | n/a | + | [tgw\_hub\_routes](#module\_tgw\_hub\_routes) | cloudposse/transit-gateway/aws | 0.10.0 | + | [tgw\_spoke\_vpc\_attachment](#module\_tgw\_spoke\_vpc\_attachment) | ./modules/standard_vpc_attachment | n/a | + | [this](#module\_this) | cloudposse/label/null | 0.25.0 | + | [vpc](#module\_vpc) | cloudposse/stack-config/yaml//modules/remote-state | 1.5.0 | + + ## Resources + + | Name | Type | + |------|------| + | [aws_route.back_route](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route) | resource | + | [aws_route.default_route](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route) | resource | + + ## 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 | + | [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 | + | [connections](#input\_connections) | A list of objects to define each TGW connections.

By default, each connection will look for only the default `vpc` component. |
list(object({
account = object({
stage = string
environment = optional(string, "")
tenant = optional(string, "")
})
vpc_component_names = optional(list(string), ["vpc"])
eks_component_names = optional(list(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,
"namespace": null,
"regex_replace_chars": null,
"stage": null,
"tags": {},
"tenant": null
}
| no | + | [cross\_region\_hub\_connector\_components](#input\_cross\_region\_hub\_connector\_components) | A map of cross-region hub connector components that provide this spoke with the appropriate Transit Gateway attachments IDs.
- The key should be the environment that the remote VPC is located in.
- The component is the name of the component in the remote region (e.g. `tgw/cross-region-hub-connector`)
- The environment is the region that the cross-region-hub-connector is deployed in.
e.g. the following would configure a component called `tgw/cross-region-hub-connector/use1` that is deployed in the
If use2 is the primary region, the following would be its configuration:
use1:
component: "tgw/cross-region-hub-connector"
environment: "use1" (the remote region)
and in the alternate region, the following would be its configuration:
use2:
component: "tgw/cross-region-hub-connector"
environment: "use1" (our own region) | `map(object({ component = string, environment = string }))` | `{}` | no | + | [default\_route\_enabled](#input\_default\_route\_enabled) | Enable default routing via transit gateway, requires also nat gateway and instance to be disabled in vpc component. Default is disabled. | `bool` | `false` | no | + | [default\_route\_outgoing\_account\_name](#input\_default\_route\_outgoing\_account\_name) | The account name which is used for outgoing traffic, when using the transit gateway as default route. | `string` | `null` | 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 | + | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | + | [environment](#input\_environment) | ID element. Usually used for region e.g. 'uw2', 'us-west-2', OR role 'prod', 'staging', 'dev', 'UAT' | `string` | `null` | no | + | [expose\_eks\_sg](#input\_expose\_eks\_sg) | Set true to allow EKS clusters to accept traffic from source accounts | `bool` | `true` | 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 | + | [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 | + | [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 | + | [namespace](#input\_namespace) | ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique | `string` | `null` | no | + | [own\_eks\_component\_names](#input\_own\_eks\_component\_names) | The name of the eks components in the owning account. | `list(string)` | `[]` | no | + | [own\_vpc\_component\_name](#input\_own\_vpc\_component\_name) | The name of the vpc component in the owning account. Defaults to "vpc" | `string` | `"vpc"` | no | + | [peered\_region](#input\_peered\_region) | Set `true` if this region is not the primary region | `bool` | `false` | 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) | AWS Region | `string` | n/a | yes | + | [stage](#input\_stage) | ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | no | + | [static\_routes](#input\_static\_routes) | A list of static routes to add to the transit gateway, pointing at this VPC as a destination. |
set(object({
blackhole = bool
destination_cidr_block = string
}))
| `[]` | no | + | [static\_tgw\_routes](#input\_static\_tgw\_routes) | A list of static routes to add to the local routing table with the transit gateway as a destination. | `list(string)` | `[]` | 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 | + | [tenant](#input\_tenant) | ID element \_(Rarely used, not included by default)\_. A customer identifier, indicating who this instance of a resource is for | `string` | `null` | no | + | [tgw\_hub\_component\_name](#input\_tgw\_hub\_component\_name) | The name of the transit-gateway component | `string` | `"tgw/hub"` | no | + | [tgw\_hub\_stage\_name](#input\_tgw\_hub\_stage\_name) | The name of the stage where `tgw/hub` is provisioned | `string` | `"network"` | no | + | [tgw\_hub\_tenant\_name](#input\_tgw\_hub\_tenant\_name) | The name of the tenant where `tgw/hub` is provisioned.

If the `tenant` label is not used, leave this as `null`. | `string` | `null` | no | + + ## Outputs + + No outputs. + + + + ## References + + - [cloudposse/terraform-aws-components](https://github.com/cloudposse/terraform-aws-components/tree/main/modules/tgw) - + Cloud Posse's upstream component +tags: + - component/tgw/spoke + - layer/network + - provider/aws # Categories of this project categories: - - terraform-modules/root - - terraform-components - + - component/tgw/spoke + - layer/network + - provider/aws # License of this project license: "APACHE2" - # Badges to display badges: - - name: "Latest Release" - image: "https://img.shields.io/github/release/cloudposse-terraform-components/template.svg?style=for-the-badge" - url: "https://github.com/cloudposse-terraform-components/template/releases/latest" - - name: "Slack Community" - image: "https://slack.cloudposse.com/for-the-badge.svg" - url: "https://slack.cloudposse.com" - -references: - - name: "Cloud Posse Documentation" - description: "Complete documentation for the Cloud Posse solution" - url: "https://docs.cloudposse.com" - - name: "Reference Architectures" - description: "Launch effortlessly with our turnkey reference architectures, built either by your team or ours." - url: "https://cloudposse.com/" - + - name: Latest Release + image: https://img.shields.io/github/release/cloudposse-terraform-components/aws-tgw-spoke.svg?style=for-the-badge + url: https://github.com/cloudposse-terraform-components/aws-tgw-spoke/releases/latest + - name: Slack Community + image: https://slack.cloudposse.com/for-the-badge.svg + url: https://slack.cloudposse.com related: -- name: "Cloud Posse Terraform Modules" - description: Our collection of reusable Terraform modules used by our reference architectures. - url: "https://docs.cloudposse.com/modules/" -- name: "Atmos" - description: "Atmos is like docker-compose but for your infrastructure" - url: "https://atmos.tools" - + - name: "Cloud Posse Terraform Modules" + description: Our collection of reusable Terraform modules used by our reference architectures. + url: "https://docs.cloudposse.com/modules/" + - name: "Atmos" + description: "Atmos is like docker-compose but for your infrastructure" + url: "https://atmos.tools" contributors: [] # If included generates contribs diff --git a/src/main.tf b/src/main.tf index 37156cf..42ad86c 100644 --- a/src/main.tf +++ b/src/main.tf @@ -1,8 +1,79 @@ +# Create the Transit Gateway, route table associations/propagations, and static TGW routes in the `network` account. +# Enable sharing the Transit Gateway with the Organization using Resource Access Manager (RAM). +# If you would like to share resources with your organization or organizational units, +# then you must use the AWS RAM console or CLI command to enable sharing with AWS Organizations. +# When you share resources within your organization, +# AWS RAM does not send invitations to principals. Principals in your organization get access to shared resources without exchanging invitations. +# https://docs.aws.amazon.com/ram/latest/userguide/getting-started-sharing.html + locals { - enabled = module.this.enabled + spoke_account = module.this.tenant != null ? format("%s-%s-%s", module.this.tenant, module.this.environment, module.this.stage) : format("%s-%s", module.this.environment, module.this.stage) + // "When default routing via transit gateway is enabled, both nat gateway and nat instance must be disabled" + default_route_enabled_and_nat_disabled = module.this.enabled && var.default_route_enabled && length(module.vpc.outputs.nat_gateway_ids) == 0 && length(module.vpc.outputs.nat_instance_ids) == 0 +} + +module "tgw_hub_routes" { + source = "cloudposse/transit-gateway/aws" + version = "0.10.0" + + providers = { + aws = aws.tgw-hub + } + + ram_resource_share_enabled = false + route_keys_enabled = false + + create_transit_gateway = false + create_transit_gateway_route_table = false + create_transit_gateway_vpc_attachment = false + create_transit_gateway_route_table_association_and_propagation = true + + config = { + (local.spoke_account) = module.tgw_spoke_vpc_attachment.tg_config, + } + + existing_transit_gateway_route_table_id = module.tgw_hub.outputs.transit_gateway_route_table_id + + context = module.this.context } +module "tgw_spoke_vpc_attachment" { + source = "./modules/standard_vpc_attachment" + owning_account = local.spoke_account + own_vpc_component_name = var.own_vpc_component_name + own_eks_component_names = var.own_eks_component_names + tgw_config = module.tgw_hub.outputs.tgw_config + tgw_connector_config = module.cross_region_hub_connector + connections = var.connections + expose_eks_sg = var.expose_eks_sg + peered_region = var.peered_region + static_routes = var.static_routes + static_tgw_routes = var.static_tgw_routes + context = module.this.context +} +resource "aws_route" "default_route" { + count = local.default_route_enabled_and_nat_disabled ? length(module.vpc.outputs.private_route_table_ids) : 0 + + route_table_id = module.vpc.outputs.private_route_table_ids[count.index] + destination_cidr_block = "0.0.0.0/0" + transit_gateway_id = module.tgw_hub.outputs.transit_gateway_id +} + +locals { + outgoing_network_account_name = local.default_route_enabled_and_nat_disabled ? format("%s-%s", var.default_route_outgoing_account_name, var.own_vpc_component_name) : "" + default_route_vpc_public_route_table_ids = local.default_route_enabled_and_nat_disabled ? module.tgw_hub.outputs.vpcs[local.outgoing_network_account_name].outputs.public_route_table_ids : [] +} + +resource "aws_route" "back_route" { + provider = aws.tgw-hub + + count = local.default_route_enabled_and_nat_disabled ? length(local.default_route_vpc_public_route_table_ids) : 0 + + route_table_id = local.default_route_vpc_public_route_table_ids[count.index] + destination_cidr_block = module.vpc.outputs.vpc_cidr + transit_gateway_id = module.tgw_hub.outputs.transit_gateway_id +} diff --git a/src/modules/standard_vpc_attachment/context.tf b/src/modules/standard_vpc_attachment/context.tf new file mode 100644 index 0000000..5e0ef88 --- /dev/null +++ b/src/modules/standard_vpc_attachment/context.tf @@ -0,0 +1,279 @@ +# +# ONLY EDIT THIS FILE IN github.com/cloudposse/terraform-null-label +# All other instances of this file should be a copy of that one +# +# +# Copy this file from https://github.com/cloudposse/terraform-null-label/blob/master/exports/context.tf +# and then place it in your Terraform module to automatically get +# Cloud Posse's standard configuration inputs suitable for passing +# to Cloud Posse modules. +# +# curl -sL https://raw.githubusercontent.com/cloudposse/terraform-null-label/master/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 = "cloudposse/label/null" + version = "0.25.0" # requires Terraform >= 0.13.0 + + enabled = var.enabled + namespace = var.namespace + tenant = var.tenant + environment = var.environment + stage = var.stage + 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 + descriptor_formats = var.descriptor_formats + labels_as_tags = var.labels_as_tags + + context = var.context +} + +# Copy contents of cloudposse/terraform-null-label/variables.tf here + +variable "context" { + type = any + default = { + enabled = true + namespace = null + tenant = null + environment = null + stage = 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 + 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 "enabled" { + type = bool + default = null + description = "Set to false to prevent the module from creating any resources" +} + +variable "namespace" { + type = string + default = null + description = "ID element. Usually an abbreviation of your organization name, e.g. 'eg' or 'cp', to help ensure generated IDs are globally unique" +} + +variable "tenant" { + type = string + default = null + description = "ID element _(Rarely used, not included by default)_. A customer identifier, indicating who this instance of a resource is for" +} + +variable "environment" { + type = string + default = null + description = "ID element. Usually used for region e.g. 'uw2', 'us-west-2', OR role 'prod', 'staging', 'dev', 'UAT'" +} + +variable "stage" { + type = string + default = null + description = "ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release'" +} + +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 +} + +#### End of copy of cloudposse/terraform-null-label/variables.tf diff --git a/src/modules/standard_vpc_attachment/main.tf b/src/modules/standard_vpc_attachment/main.tf new file mode 100644 index 0000000..634975b --- /dev/null +++ b/src/modules/standard_vpc_attachment/main.tf @@ -0,0 +1,188 @@ +locals { + vpcs = var.tgw_config.vpcs + eks = var.tgw_config.eks + + own_account_vpc_key = "${var.owning_account}-${var.own_vpc_component_name}" + own_vpc = local.vpcs[local.own_account_vpc_key].outputs + is_network_hub = (module.this.stage == var.network_account_stage_name) ? true : false + + # Create a list of all VPC component keys. Key includes stack + component + # + # Example var.connections + # connections: + # - account: + # tenant: core + # stage: network + # vpc_component_names: + # - vpc-dev + # - account: + # tenant: core + # stage: auto + # - account: + # tenant: plat + # stage: dev + # - account: + # tenant: plat + # stage: dev + # environment: usw2 + connected_vpc_component_keys = flatten( + [ + for c in var.connections : + [ + # Default value for c.vpc_component_names is ["vpc"] + for vpc in c.vpc_component_names : + # This component key needs to match the key created by tgw/hub + # See components/terraform/tgw/hub/remote-state.tf + length(c.account.environment) > 0 ? + (length(c.account.tenant) > 0 ? + "${c.account.tenant}-${c.account.environment}-${c.account.stage}-${vpc}" : + "${c.account.environment}-${c.account.stage}-${vpc}") + : + (length(c.account.tenant) > 0 ? + "${c.account.tenant}-${module.this.environment}-${c.account.stage}-${vpc}" : + "${module.this.environment}-${c.account.stage}-${vpc}") + ] + ] + ) + + # Create a list of all EKS component keys. + # Follows same pattern as vpc_component_names + connected_eks_component_keys = flatten( + [ + for c in var.connections : + [ + for eks in c.eks_component_names : + length(c.account.environment) > 0 ? + (length(c.account.tenant) > 0 ? + "${c.account.tenant}-${c.account.environment}-${c.account.stage}-${eks}" : + "${c.account.environment}-${c.account.stage}-${eks}") + : + (length(c.account.tenant) > 0 ? + "${c.account.tenant}-${module.this.environment}-${c.account.stage}-${eks}" : + "${module.this.environment}-${c.account.stage}-${eks}") + ] + ] + ) + + # Define a list of all VPCs allowed to access this account's VPC. + # Filter the tgw_config output from tgw/hub for VPCs and pull the CIDR of a VPC if + # (1) this is not the primary VPC that we are connecting to and (2) this VPC key is given as a connection + allowed_vpcs = { + for vpc_key, vpc_remote_state in local.vpcs : + vpc_key => { + cidr = vpc_remote_state.outputs.vpc_cidr + cross_region = (vpc_remote_state.outputs.environment != module.this.environment) + environment = vpc_remote_state.outputs.environment + } if vpc_key != local.own_account_vpc_key && contains(local.connected_vpc_component_keys, vpc_key) + } + + # For each EKS cluster in this account, map the EKS SG to the CIDR for each connected cluster + allowed_eks = merge([ + for own_eks_component in var.own_eks_component_names : + { + for eks_key, eks_remote_state in local.eks : + eks_key => { + # SG of each EKS component in this account + sg_id = local.eks["${var.owning_account}-${own_eks_component}"].outputs.eks_cluster_managed_security_group_id + # CIDR of the remote EKS cluster + cidr = eks_remote_state.outputs.vpc_cidr + } if contains(local.connected_eks_component_keys, eks_key) + } + ]...) + + cross_region_vpcs = flatten([ + for vpc_key, vpc in local.allowed_vpcs : [ + { + vpc_key = vpc_key + cidr = vpc.cidr + environment = vpc.environment + } + ] if vpc.cross_region + ]) + + cross_region_vpc_route_table_ids = flatten([ + for vpc_key, vpc in local.allowed_vpcs : [ + for route_table_key, route_table_id in local.own_vpc.private_route_table_ids : [ + { + vpc_key = vpc_key + rt_key = route_table_key + cidr = vpc.cidr + route_table_id = route_table_id + } + ] + ] if vpc.cross_region + ]) +} + +# Create a TGW attachment from this account's VPC to the TGW Hub +# This includes a merged list of all CIDRs from allowed VPCs in connected accounts +module "standard_vpc_attachment" { + source = "cloudposse/transit-gateway/aws" + version = "0.11.0" + + existing_transit_gateway_id = var.tgw_config.existing_transit_gateway_id + existing_transit_gateway_route_table_id = var.tgw_config.existing_transit_gateway_route_table_id + + route_keys_enabled = true + create_transit_gateway = false + create_transit_gateway_route_table = false + create_transit_gateway_vpc_attachment = true + create_transit_gateway_route_table_association_and_propagation = false + + config = { + (var.owning_account) = { + vpc_id = local.own_vpc.vpc_id + vpc_cidr = local.own_vpc.vpc_cidr + subnet_ids = local.own_vpc.private_subnet_ids + subnet_route_table_ids = local.own_vpc.private_route_table_ids + route_to = null + static_routes = var.static_routes + transit_gateway_vpc_attachment_id = null + route_to_cidr_blocks = concat([for vpc in local.allowed_vpcs : vpc.cidr if !vpc.cross_region], var.static_tgw_routes) + } + } + + context = module.this.context +} + +# Create a TGW attachment for a Peering Connection +# This in only necessary in the hub accounts +resource "aws_ec2_transit_gateway_route" "peering_connection" { + for_each = local.is_network_hub ? { + for vpc in local.cross_region_vpcs : vpc.cidr => vpc + } : {} + + # Use the TGW Attachment in the alternate, peered region + transit_gateway_attachment_id = var.peered_region ? var.tgw_connector_config[local.own_vpc.environment].outputs.aws_ec2_transit_gateway_peering_attachment_id : var.tgw_connector_config[each.value.environment].outputs.aws_ec2_transit_gateway_peering_attachment_id + + blackhole = false + destination_cidr_block = each.value.cidr + transit_gateway_route_table_id = var.tgw_config.existing_transit_gateway_route_table_id +} + +# Route this VPC to the destination CIDR +# This is only necessary in cross-region connections +resource "aws_route" "peering_connection" { + for_each = { + for vpc_rt in local.cross_region_vpc_route_table_ids : "${vpc_rt.route_table_id}:${vpc_rt.cidr}" => vpc_rt + } + + transit_gateway_id = var.tgw_config.existing_transit_gateway_id + + route_table_id = each.value.route_table_id + destination_cidr_block = each.value.cidr +} + +# Define a Security Group Rule to allow traffic from +# Expose traffic from EKS VPC CIDRs in other accounts to this accounts EKS cluster SG +resource "aws_security_group_rule" "ingress_cidr_blocks" { + for_each = var.expose_eks_sg ? local.allowed_eks : {} + + description = "Allow inbound traffic from ${each.key}" + type = "ingress" + from_port = 0 + to_port = 65535 + protocol = "tcp" + cidr_blocks = [each.value.cidr] # CIDR of cluster in other accounts + security_group_id = each.value.sg_id # SG of cluster in this account +} diff --git a/src/modules/standard_vpc_attachment/outputs.tf b/src/modules/standard_vpc_attachment/outputs.tf new file mode 100644 index 0000000..3c21a67 --- /dev/null +++ b/src/modules/standard_vpc_attachment/outputs.tf @@ -0,0 +1,14 @@ +output "tg_config" { + ## Fit tg config type https://github.com/cloudposse/terraform-aws-transit-gateway#input_config + value = { + vpc_id = null + vpc_cidr = null + subnet_ids = null + subnet_route_table_ids = null + route_to = null + route_to_cidr_blocks = null + static_routes = var.static_routes + transit_gateway_vpc_attachment_id = module.standard_vpc_attachment.transit_gateway_vpc_attachment_ids[var.owning_account] + } + description = "Transit Gateway configuration formatted for handling" +} diff --git a/src/modules/standard_vpc_attachment/variables.tf b/src/modules/standard_vpc_attachment/variables.tf new file mode 100644 index 0000000..8722bbd --- /dev/null +++ b/src/modules/standard_vpc_attachment/variables.tf @@ -0,0 +1,86 @@ +variable "owning_account" { + type = string + default = null + description = "The name of the account that owns the VPC being attached" +} + +variable "own_vpc_component_name" { + type = string + default = "vpc" + description = "The name of the vpc component in the owning account. Defaults to \"vpc\"" +} + +variable "own_eks_component_names" { + type = list(string) + default = [] + description = "The name of the eks components in the owning account." +} + +variable "tgw_config" { + type = object({ + existing_transit_gateway_id = string + existing_transit_gateway_route_table_id = string + vpcs = any + eks = any + }) + description = "Object to pass common data from root module to this submodule. See root module for details" +} + +variable "tgw_connector_config" { + type = map(any) + description = "Map of output from all `tgw/cross-region-hub-connector` components. See root module for details" + default = {} +} + +variable "connections" { + type = list(object({ + account = object({ + stage = string + environment = optional(string, "") + tenant = optional(string, "") + }) + vpc_component_names = optional(list(string), ["vpc"]) + eks_component_names = optional(list(string), []) + })) + description = <<-EOT + A list of objects to define each TGW connections. + + By default, each connection will look for only the default `vpc` component. + EOT + default = [] +} + +variable "static_routes" { + type = set(object({ + blackhole = bool + destination_cidr_block = string + })) + description = <<-EOT + A list of static routes. + EOT + default = [] +} + +variable "static_tgw_routes" { + type = list(string) + description = "A list of static routes to add to the local routing table with the transit gateway as a destination." + default = [] +} + +variable "expose_eks_sg" { + type = bool + description = "Set true to allow EKS clusters to accept traffic from source accounts" + default = true +} + +variable "peered_region" { + type = bool + description = "Set `true` if this region is not the primary region" + default = false +} + +variable "network_account_stage_name" { + type = string + description = "The name of the stage designated as the network hub" + default = "network" +} diff --git a/src/modules/standard_vpc_attachment/versions.tf b/src/modules/standard_vpc_attachment/versions.tf new file mode 100644 index 0000000..f33ede7 --- /dev/null +++ b/src/modules/standard_vpc_attachment/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 4.0" + } + } +} diff --git a/src/outputs.tf b/src/outputs.tf index 3d08cfa..e69de29 100644 --- a/src/outputs.tf +++ b/src/outputs.tf @@ -1,4 +0,0 @@ -output "mock" { - description = "Mock output example for the Cloud Posse Terraform component template" - value = local.enabled ? "hello ${basename(abspath(path.module))}" : "" -} diff --git a/src/provider-hub.tf b/src/provider-hub.tf new file mode 100644 index 0000000..a1f429f --- /dev/null +++ b/src/provider-hub.tf @@ -0,0 +1,24 @@ +provider "aws" { + alias = "tgw-hub" + region = var.region + + # Profile is deprecated in favor of terraform_role_arn. When profiles are not in use, terraform_profile_name is null. + profile = module.tgw_hub_role.terraform_profile_name + + dynamic "assume_role" { + # module.tgw_hub_role.terraform_role_arn may be null, in which case do not assume a role. + for_each = compact([module.tgw_hub_role.terraform_role_arn]) + content { + role_arn = assume_role.value + } + } +} + +module "tgw_hub_role" { + source = "../../account-map/modules/iam-roles" + + stage = var.tgw_hub_stage_name + tenant = var.tgw_hub_tenant_name + + context = module.this.context +} diff --git a/src/providers.tf b/src/providers.tf new file mode 100644 index 0000000..89ed50a --- /dev/null +++ b/src/providers.tf @@ -0,0 +1,19 @@ +provider "aws" { + region = var.region + + # Profile is deprecated in favor of terraform_role_arn. When profiles are not in use, terraform_profile_name is null. + profile = module.iam_roles.terraform_profile_name + + dynamic "assume_role" { + # module.iam_roles.terraform_role_arn may be null, in which case do not assume a role. + for_each = compact([module.iam_roles.terraform_role_arn]) + content { + role_arn = assume_role.value + } + } +} + +module "iam_roles" { + source = "../../account-map/modules/iam-roles" + context = module.this.context +} diff --git a/src/remote-state.tf b/src/remote-state.tf new file mode 100644 index 0000000..709e922 --- /dev/null +++ b/src/remote-state.tf @@ -0,0 +1,37 @@ +module "tgw_hub" { + source = "cloudposse/stack-config/yaml//modules/remote-state" + version = "1.5.0" + + component = var.tgw_hub_component_name + tenant = length(var.tgw_hub_tenant_name) > 0 ? var.tgw_hub_tenant_name : module.this.tenant + stage = length(var.tgw_hub_stage_name) > 0 ? var.tgw_hub_stage_name : module.this.stage + + context = module.this.context +} + +module "vpc" { + source = "cloudposse/stack-config/yaml//modules/remote-state" + version = "1.5.0" + + component = var.own_vpc_component_name + + context = module.this.context +} + +module "cross_region_hub_connector" { + source = "cloudposse/stack-config/yaml//modules/remote-state" + version = "1.5.0" + + for_each = var.cross_region_hub_connector_components + + component = each.value.component + tenant = length(var.tgw_hub_tenant_name) > 0 ? var.tgw_hub_tenant_name : module.this.tenant + stage = length(var.tgw_hub_stage_name) > 0 ? var.tgw_hub_stage_name : module.this.stage + environment = each.value.environment + + # Ignore if hub connector doesnt exist (it doesnt exist in primary region) + ignore_errors = true + defaults = {} + + context = module.this.context +} diff --git a/src/variables.tf b/src/variables.tf new file mode 100644 index 0000000..a4eec4e --- /dev/null +++ b/src/variables.tf @@ -0,0 +1,115 @@ +variable "region" { + type = string + description = "AWS Region" +} + +variable "connections" { + type = list(object({ + account = object({ + stage = string + environment = optional(string, "") + tenant = optional(string, "") + }) + vpc_component_names = optional(list(string), ["vpc"]) + eks_component_names = optional(list(string), []) + })) + description = <<-EOT + A list of objects to define each TGW connections. + + By default, each connection will look for only the default `vpc` component. + EOT + default = [] +} + +variable "tgw_hub_component_name" { + type = string + description = "The name of the transit-gateway component" + default = "tgw/hub" +} + +variable "tgw_hub_stage_name" { + type = string + description = "The name of the stage where `tgw/hub` is provisioned" + default = "network" +} + +variable "tgw_hub_tenant_name" { + type = string + description = <<-EOT + The name of the tenant where `tgw/hub` is provisioned. + + If the `tenant` label is not used, leave this as `null`. + EOT + default = null +} + +variable "expose_eks_sg" { + type = bool + description = "Set true to allow EKS clusters to accept traffic from source accounts" + default = true +} + +variable "own_vpc_component_name" { + type = string + default = "vpc" + description = "The name of the vpc component in the owning account. Defaults to \"vpc\"" +} + +variable "own_eks_component_names" { + type = list(string) + default = [] + description = "The name of the eks components in the owning account." +} + +variable "peered_region" { + type = bool + description = "Set `true` if this region is not the primary region" + default = false +} + +variable "static_routes" { + type = set(object({ + blackhole = bool + destination_cidr_block = string + })) + description = "A list of static routes to add to the transit gateway, pointing at this VPC as a destination." + default = [] +} + +variable "static_tgw_routes" { + type = list(string) + description = "A list of static routes to add to the local routing table with the transit gateway as a destination." + default = [] +} + +variable "default_route_enabled" { + type = bool + description = "Enable default routing via transit gateway, requires also nat gateway and instance to be disabled in vpc component. Default is disabled." + default = false +} + +variable "default_route_outgoing_account_name" { + type = string + description = "The account name which is used for outgoing traffic, when using the transit gateway as default route." + default = null +} + +variable "cross_region_hub_connector_components" { + type = map(object({ component = string, environment = string })) + description = <<-EOT + A map of cross-region hub connector components that provide this spoke with the appropriate Transit Gateway attachments IDs. + - The key should be the environment that the remote VPC is located in. + - The component is the name of the component in the remote region (e.g. `tgw/cross-region-hub-connector`) + - The environment is the region that the cross-region-hub-connector is deployed in. + e.g. the following would configure a component called `tgw/cross-region-hub-connector/use1` that is deployed in the + If use2 is the primary region, the following would be its configuration: + use1: + component: "tgw/cross-region-hub-connector" + environment: "use1" (the remote region) + and in the alternate region, the following would be its configuration: + use2: + component: "tgw/cross-region-hub-connector" + environment: "use1" (our own region) + EOT + default = {} +} diff --git a/src/versions.tf b/src/versions.tf index e2a3d73..f0e7120 100644 --- a/src/versions.tf +++ b/src/versions.tf @@ -1,5 +1,10 @@ terraform { required_version = ">= 1.0.0" - required_providers {} + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 4.1" + } + } }