From 503f50c2fbf6974eee8091220ee3c246ba035931 Mon Sep 17 00:00:00 2001 From: Nuru Date: Fri, 20 Aug 2021 11:46:03 -0700 Subject: [PATCH] Add "tenant", "labels_as_tags", and "descriptors" (#132) --- README.md | 313 +++++++++++++------- README.yaml | 270 +++++++++++------ descriptors.tf | 28 ++ docs/terraform.md | 41 +-- examples/complete/.gitignore | 1 + examples/complete/compatibility.tf | 286 ++++++++++++++++++ examples/complete/context.tf | 139 ++++++--- examples/complete/descriptors.tf | 42 +++ examples/complete/label1.tf | 5 +- examples/complete/label1t1.tf | 2 +- examples/complete/label1t2.tf | 2 +- examples/complete/label2.tf | 8 +- examples/complete/label3c.tf | 1 + examples/complete/label3n.tf | 1 + examples/complete/label8d.tf | 25 +- examples/complete/module/compare/compare.tf | 48 +++ exports/context.tf | 109 ++++++- main.tf | 66 ++++- outputs.tf | 22 +- test/src/examples_complete_test.go | 44 ++- variables.tf | 101 ++++++- 21 files changed, 1221 insertions(+), 333 deletions(-) create mode 100644 descriptors.tf create mode 100644 examples/complete/compatibility.tf create mode 100644 examples/complete/descriptors.tf create mode 100644 examples/complete/module/compare/compare.tf diff --git a/README.md b/README.md index 834b4f0..8aadeef 100644 --- a/README.md +++ b/README.md @@ -30,21 +30,58 @@ Terraform module designed to generate consistent names and tags for resources. Use `terraform-null-label` to implement a strict naming convention. -This module generates names using the following convention by default: `{namespace}-{environment}-{stage}-{name}-{attributes}`. +There are 6 inputs considered "labels" or "ID elements" (because the labels are used to construct the ID): +1. namespace +1. tenant +1. environment +1. stage +1. name +1. attributes + +This module generates IDs using the following convention by default: `{namespace}-{environment}-{stage}-{name}-{attributes}`. However, it is highly configurable. The delimiter (e.g. `-`) is configurable. Each label item is optional (although you must provide at least one). -So if you prefer the term `stage` to `environment` -you can exclude environment and the label `id` will look like `{namespace}-{stage}-{name}-{attributes}`. -- If attributes are excluded but `namespace`, `stage`, and `environment` are included, `id` will look like `{namespace}-{environment}-{stage}-{name}`. -- If you want the attributes in a different order, you can specify that, too, with the `label_order` list. -- You can set a maximum length for the name, and the module will create a unique name that fits within that length. +So if you prefer the term `stage` to `environment` and do not need `tenant`, you can exclude them +and the label `id` will look like `{namespace}-{stage}-{name}-{attributes}`. +- The `tenant` label was introduced in v0.25.0. To preserve backward compatibility, it is not included by default. +- The `attributes` input is actually a list of strings and `{attributes}` expands to the list elements joined by the delimiter. +- If `attributes` is excluded but `namespace`, `stage`, and `environment` are included, `id` will look like `{namespace}-{environment}-{stage}-{name}`. + Excluding `attributes` is discouraged, though, because attributes are the main way modules modify the ID to ensure uniqueness when provisioning the same resource types. +- If you want the label items in a different order, you can specify that, too, with the `label_order` list. +- You can set a maximum length for the `id`, and the module will create a (probably) unique name that fits within that length. + (The module uses a portion of the MD5 hash of the full `id` to represent the missing part, so there remains a slight chance of name collision.) - You can control the letter case of the generated labels which make up the `id` using `var.label_value_case`. -- The labels are also exported as tags. You can control the case of the tag names (keys) using `var.label_key_case`. +- By default, all of the non-empty labels are also exported as tags, whether they appear in the `id` or not. +You can control which labels are exported as tags by setting `labels_as_tags` to the list of labels you want exported, +or the empty list `[]` if you want no labels exported as tags at all. Tags passed in via the `tags` variable are +always exported, and regardless of settings, empty labels are never exported as tags. +You can control the case of the tag names (keys) for the labels using `var.label_key_case`. +Unlike the tags generated from the label inputs, tags passed in via the `tags` input are not modified. + +There is an unfortunate collision over the use of the key `name`. Cloud Posse uses `name` in this module +to represent the component, such as `eks` or `rds`. AWS uses a tag with the key `Name` to store the full human-friendly +identifier of the thing tagged, which this module outputs as `id`, not `name`. So when converting input labels +to tags, the value of the `Name` key is set to the module `id` output, and there is no tag corresponding to the +module `name` output. An empty `name` label will not prevent the `Name` tag from being exported. It's recommended to use one `terraform-null-label` module for every unique resource of a given resource type. For example, if you have 10 instances, there should be 10 different labels. However, if you have multiple different kinds of resources (e.g. instances, security groups, file systems, and elastic ips), then they can all share the same label assuming they are logically related. -All [Cloud Posse modules](https://github.com/cloudposse?utf8=%E2%9C%93&q=terraform-&type=&language=) use this module to ensure resources can be instantiated multiple times within an account and without conflict. +For most purposes, the `id` output is sufficient to create an ID or label for a resource, and if you want a different +ID or a different format, you would instantiate another instance of `null-label` and configure it accordingly. However, +to accomodate situations where you want all the same inputs to generate multiple descriptors, this module provides +the `descriptors` output, which is a map of strings generated according to the format specified by the +`descriptor_formats` input. This feature is intentionally simple and minimally configurable and will not be +enhanced to add more features that are already in `null-label`. See [examples/complete/descriptors.tf](examples/complete/descriptors.tf) for examples. + +All [Cloud Posse Terraform modules](https://github.com/cloudposse?utf8=%E2%9C%93&q=terraform-&type=&language=) use this module to ensure resources can be instantiated multiple times within an account and without conflict. + +The Cloud Posse convention is to use labels as follows: +- `namespace`: A short (3-4 letters) abbreviation of the company name, to ensure globally unique IDs for things like S3 buckets +- `tenant`: _(Rarely needed)_ When a company creates a dedicated resource per customer, `tenant` can be used to identify the customer the resource is dedicated to +- `environment`: A [short abbreviation](https://github.com/cloudposse/terraform-aws-utils/#introduction) for the AWS region hosting the resource, or `gbl` for resources like IAM roles that have no region +- `stage`: The name or role of the account the resource is for, such as `prod` or `dev` +- `name`: The name of the component that owns the resources, such as `eks` or `rds` **NOTE:** The `null` originally referred to the primary Terraform [provider](https://www.terraform.io/docs/providers/null/index.html) used in this module. With Terraform 0.12, this module no longer needs any provider, but the name was kept for continuity. @@ -123,25 +160,30 @@ The context object is a single object that contains all the input values for `te However, each input value can also be specified individually by name as a standard Terraform variable, and the value of those variables, when set to something other than `null`, will override the value in the context object. In order to allow chaining of these objects, where the context object input to one -module is transformed and passed to the next module, all the variables default to `null` or empty collections. -The actual default values used when nothing is explicitly set are describe in the documentation below. +module is transformed and passed on to the next module, all the variables default to `null` or empty collections. +The actual default values used when nothing is explicitly set are described in the documentation below. -For example, the default value of `delimiter` is shown as `null`, but if you leave it set to null, +For example, the default value of `delimiter` is shown as `null`, but if you leave it set to `null`, `terraform-null-label` will actually use the default delimiter `-` (hyphen). A non-obvious but intentional consequence of this design is that once a module sets a non-default value, -future modules in the chain cannot reset the value back to the original default. Insted, the new setting +future modules in the chain cannot reset the value back to the original default. Instead, the new setting becomes the new default for downstream modules. Also, collections are not overwritten, they are merged, so once a tag is added, it will remain in the tag set and cannot be removed, although its value can be overwritten. +Because the purpose of `labels_as_tags` is primarily to prevent tags from being generated +that would [conflict with the AWS provider's `default_tags`](https://github.com/hashicorp/terraform-provider-aws/issues/19204), it is an exception to the +rule that variables override the setting in the context object. The value in the context +object cannot be changed, so that later modules cannot re-enable a problematic tag. + ### Simple Example ```hcl module "eg_prod_bastion_label" { source = "cloudposse/label/null" # Cloud Posse recommends pinning every module to a specific version - # version = "x.x.x" + # version = "x.x.x" namespace = "eg" stage = "prod" @@ -192,15 +234,14 @@ Here is a more complex example with two instances using two different labels. No
Click to show ```hcl -module "eg_prod_bastion_abc_label" { +module "eg_prod_bastion_label" { source = "cloudposse/label/null" # Cloud Posse recommends pinning every module to a specific version - # version = "x.x.x" + # version = "x.x.x" namespace = "eg" stage = "prod" name = "bastion" - attributes = ["abc"] delimiter = "-" tags = { @@ -209,6 +250,21 @@ module "eg_prod_bastion_abc_label" { } } +module "eg_prod_bastion_abc_label" { + source = "cloudposse/label/null" + # Cloud Posse recommends pinning every module to a specific version + # version = "x.x.x" + + attributes = ["abc"] + + tags = { + "BusinessUnit" = "ABC" # Override the Business Unit tag set in the base label + } + + # Copy all other fields from the base label + context = module.eg_prod_bastion_label.context +} + resource "aws_security_group" "eg_prod_bastion_abc" { name = module.eg_prod_bastion_abc_label.id tags = module.eg_prod_bastion_abc_label.tags @@ -223,24 +279,17 @@ resource "aws_security_group" "eg_prod_bastion_abc" { resource "aws_instance" "eg_prod_bastion_abc" { instance_type = "t1.micro" tags = module.eg_prod_bastion_abc_label.tags -   vpc_security_group_ids = [aws_security_group.eg_prod_bastion_abc.id] + vpc_security_group_ids = [aws_security_group.eg_prod_bastion_abc.id] } module "eg_prod_bastion_xyz_label" { source = "cloudposse/label/null" # Cloud Posse recommends pinning every module to a specific version - # version = "x.x.x" + # version = "x.x.x" - namespace = "eg" - stage = "prod" - name = "bastion" attributes = ["xyz"] - delimiter = "-" - tags = { - "BusinessUnit" = "XYZ", - "Snapshot" = "true" - } + context = module.eg_prod_bastion_label.context } resource "aws_security_group" "eg_prod_bastion_xyz" { @@ -265,26 +314,27 @@ resource "aws_instance" "eg_prod_bastion_xyz" { ### Advanced Example 2 -Here is a more complex example with an autoscaling group that has a different tagging schema than other resources and requires its tags to be in this format, which this module can generate: +Here is a more complex example with an autoscaling group that has a different tagging schema than other resources and +requires its tags to be in this format, which this module can generate via `additional_tag_map` and `tags_as_list_of_maps`:
Click to show ```hcl tags = [ { - key = Name, - propagate_at_launch = 1, - value = namespace-stage-name + key = "Name", + propagate_at_launch = true, + value = "namespace-stage-name" }, { - key = Namespace, - propagate_at_launch = 1, - value = namespace + key = "Namespace", + propagate_at_launch = true, + value = "namespace" }, { - key = Stage, - propagate_at_launch = 1, - value = stage + key = "Stage", + propagate_at_launch = true, + value = "stage" } ] ``` @@ -307,7 +357,7 @@ module "label" { } additional_tag_map = { - propagate_at_launch = "true" + propagate_at_launch = true } } @@ -340,12 +390,12 @@ resource "aws_autoscaling_group" "default" { # terraform-null-label example used here: Set ASG name prefix name_prefix = "${module.label.id}-" vpc_zone_identifier = data.aws_subnet_ids.all.ids - max_size = "1" - min_size = "1" - desired_capacity = "1" + max_size = 1 + min_size = 1 + desired_capacity = 1 launch_template = { - id = "aws_launch_template.default.id + id = aws_launch_template.default.id version = "$$Latest" } @@ -373,13 +423,13 @@ module "label1" { # version = "x.x.x" namespace = "CloudPosse" + tenant = "H.R.H" environment = "UAT" stage = "build" name = "Winston Churchroom" attributes = ["fire", "water", "earth", "air"] - delimiter = "-" - label_order = ["name", "environment", "stage", "attributes"] + label_order = ["name", "tenant", "environment", "stage", "attributes"] tags = { "City" = "Dublin" @@ -392,15 +442,23 @@ module "label2" { # Cloud Posse recommends pinning every module to a specific version # version = "x.x.x" - context = module.label1.context name = "Charlie" + tenant = "" # setting to `null` would have no effect stage = "test" delimiter = "+" + regex_replace_chars = "/[^a-zA-Z0-9-+]/" + + additional_tag_map = { + propagate_at_launch = true + additional_tag = "yes" + } tags = { "City" = "London" "Environment" = "Public" } + + context = module.label1.context } module "label3" { @@ -410,13 +468,15 @@ module "label3" { name = "Starfish" stage = "release" - context = module.label1.context delimiter = "." + regex_replace_chars = "/[^-a-zA-Z0-9.]/" tags = { "Eat" = "Carrot" "Animal" = "Rabbit" } + + context = module.label1.context } ``` @@ -424,61 +484,71 @@ This creates label outputs like this: ```hcl label1 = { - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "-" - "id" = "winstonchurchroom-uat-build-fire-water-earth-air" + "id" = "winstonchurchroom-hrh-uat-build-fire-water-earth-air" "name" = "winstonchurchroom" "namespace" = "cloudposse" "stage" = "build" + "tenant" = "hrh" } label1_context = { "additional_tag_map" = {} - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] - "delimiter" = "-" + ]) + "delimiter" = tostring(null) "enabled" = true "environment" = "UAT" - "label_order" = [ + "id_length_limit" = tonumber(null) + "label_key_case" = tostring(null) + "label_order" = tolist([ "name", + "tenant", "environment", "stage", "attributes", - ] + ]) + "label_value_case" = tostring(null) "name" = "Winston Churchroom" "namespace" = "CloudPosse" + "regex_replace_chars" = tostring(null) "stage" = "build" "tags" = { "City" = "Dublin" "Environment" = "Private" } + "tenant" = "H.R.H" } label1_normalized_context = { "additional_tag_map" = {} - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "-" "enabled" = true "environment" = "uat" "id_length_limit" = 0 - "label_order" = [ + "label_key_case" = "title" + "label_order" = tolist([ "name", + "tenant", "environment", "stage", "attributes", - ] + ]) + "label_value_case" = "lower" "name" = "winstonchurchroom" "namespace" = "cloudposse" "regex_replace_chars" = "/[^-a-zA-Z0-9]/" @@ -487,52 +557,60 @@ label1_normalized_context = { "Attributes" = "fire-water-earth-air" "City" = "Dublin" "Environment" = "Private" - "Name" = "winstonchurchroom-uat-build-fire-water-earth-air" + "Name" = "winstonchurchroom-hrh-uat-build-fire-water-earth-air" "Namespace" = "cloudposse" "Stage" = "build" + "Tenant" = "hrh" } + "tenant" = "hrh" } -label1_tags = { +label1_tags = tomap({ "Attributes" = "fire-water-earth-air" "City" = "Dublin" "Environment" = "Private" - "Name" = "winstonchurchroom-uat-build-fire-water-earth-air" + "Name" = "winstonchurchroom-hrh-uat-build-fire-water-earth-air" "Namespace" = "cloudposse" "Stage" = "build" -} + "Tenant" = "hrh" +}) label2 = { - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "+" "id" = "charlie+uat+test+fire+water+earth+air" "name" = "charlie" "namespace" = "cloudposse" "stage" = "test" + "tenant" = "" } label2_context = { "additional_tag_map" = { "additional_tag" = "yes" "propagate_at_launch" = "true" } - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "+" "enabled" = true "environment" = "UAT" - "label_order" = [ + "id_length_limit" = tonumber(null) + "label_key_case" = tostring(null) + "label_order" = tolist([ "name", + "tenant", "environment", "stage", "attributes", - ] + ]) + "label_value_case" = tostring(null) "name" = "Charlie" "namespace" = "CloudPosse" "regex_replace_chars" = "/[^a-zA-Z0-9-+]/" @@ -541,15 +619,16 @@ label2_context = { "City" = "London" "Environment" = "Public" } + "tenant" = "" } -label2_tags = { +label2_tags = tomap({ "Attributes" = "fire+water+earth+air" "City" = "London" "Environment" = "Public" "Name" = "charlie+uat+test+fire+water+earth+air" "Namespace" = "cloudposse" "Stage" = "test" -} +}) label2_tags_as_list_of_maps = [ { "additional_tag" = "yes" @@ -589,35 +668,40 @@ label2_tags_as_list_of_maps = [ }, ] label3 = { - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "." - "id" = "starfish.uat.release.fire.water.earth.air" + "id" = "starfish.h.r.h.uat.release.fire.water.earth.air" "name" = "starfish" "namespace" = "cloudposse" "stage" = "release" + "tenant" = "h.r.h" } label3_context = { "additional_tag_map" = {} - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "." "enabled" = true "environment" = "UAT" - "label_order" = [ + "id_length_limit" = tonumber(null) + "label_key_case" = tostring(null) + "label_order" = tolist([ "name", + "tenant", "environment", "stage", "attributes", - ] + ]) + "label_value_case" = tostring(null) "name" = "Starfish" "namespace" = "CloudPosse" "regex_replace_chars" = "/[^-a-zA-Z0-9.]/" @@ -628,25 +712,29 @@ label3_context = { "Eat" = "Carrot" "Environment" = "Private" } + "tenant" = "H.R.H" } label3_normalized_context = { "additional_tag_map" = {} - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "." "enabled" = true "environment" = "uat" "id_length_limit" = 0 - "label_order" = [ + "label_key_case" = "title" + "label_order" = tolist([ "name", + "tenant", "environment", "stage", "attributes", - ] + ]) + "label_value_case" = "lower" "name" = "starfish" "namespace" = "cloudposse" "regex_replace_chars" = "/[^-a-zA-Z0-9.]/" @@ -657,21 +745,24 @@ label3_normalized_context = { "City" = "Dublin" "Eat" = "Carrot" "Environment" = "Private" - "Name" = "starfish.uat.release.fire.water.earth.air" + "Name" = "starfish.h.r.h.uat.release.fire.water.earth.air" "Namespace" = "cloudposse" "Stage" = "release" + "Tenant" = "h.r.h" } + "tenant" = "h.r.h" } -label3_tags = { +label3_tags = tomap({ "Animal" = "Rabbit" "Attributes" = "fire.water.earth.air" "City" = "Dublin" "Eat" = "Carrot" "Environment" = "Private" - "Name" = "starfish.uat.release.fire.water.earth.air" + "Name" = "starfish.h.r.h.uat.release.fire.water.earth.air" "Namespace" = "cloudposse" "Stage" = "release" -} + "Tenant" = "h.r.h" +}) ``` @@ -717,21 +808,24 @@ No resources. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [additional\_tag\_map](#input\_additional\_tag\_map) | Additional tags for appending to tags\_as\_list\_of\_maps. Not added to `tags`. | `map(string)` | `{}` | no | -| [attributes](#input\_attributes) | Additional attributes (e.g. `1`) | `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,
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"name": null,
"namespace": null,
"regex_replace_chars": null,
"stage": null,
"tags": {}
}
| no | -| [delimiter](#input\_delimiter) | Delimiter to be used between `namespace`, `environment`, `stage`, `name` and `attributes`.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | +| [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 | +| [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 | +| [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) | Environment, e.g. 'uw2', 'us-west-2', OR 'prod', 'staging', 'dev', 'UAT' | `string` | `null` | 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 default, which is `0`.
Does not affect `id_full`. | `number` | `null` | no | -| [label\_key\_case](#input\_label\_key\_case) | The letter case of label keys (`tag` names) (i.e. `name`, `namespace`, `environment`, `stage`, `attributes`) to use in `tags`.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | -| [label\_order](#input\_label\_order) | The naming order of the id output and Name tag.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 5 elements, but at least one must be present. | `list(string)` | `null` | no | -| [label\_value\_case](#input\_label\_value\_case) | The letter case of output label values (also used in `tags` and `id`).
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Default value: `lower`. | `string` | `null` | no | -| [name](#input\_name) | Solution name, e.g. 'app' or 'jenkins' | `string` | `null` | no | -| [namespace](#input\_namespace) | Namespace, which could be your organization name or abbreviation, e.g. 'eg' or 'cp' | `string` | `null` | no | -| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Regex to replace chars with empty string in `namespace`, `environment`, `stage` and `name`.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | -| [stage](#input\_stage) | Stage, e.g. 'prod', 'staging', 'dev', OR 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | no | -| [tags](#input\_tags) | Additional tags (e.g. `map('BusinessUnit','XYZ')` | `map(string)` | `{}` | 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 | +| [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 | +| [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 | +| [stage](#input\_stage) | ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | 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 | ## Outputs @@ -740,11 +834,12 @@ No resources. | [additional\_tag\_map](#output\_additional\_tag\_map) | The merged additional\_tag\_map | | [attributes](#output\_attributes) | List of attributes | | [context](#output\_context) | Merged but otherwise unmodified input to this module, to be used as context input to other modules.
Note: this version will have null values as defaults, not the values actually used as defaults. | -| [delimiter](#output\_delimiter) | Delimiter between `namespace`, `environment`, `stage`, `name` and `attributes` | +| [delimiter](#output\_delimiter) | Delimiter between `namespace`, `tenant`, `environment`, `stage`, `name` and `attributes` | +| [descriptors](#output\_descriptors) | Map of descriptors as configured by `descriptor_formats` | | [enabled](#output\_enabled) | True if module is enabled, false otherwise | | [environment](#output\_environment) | Normalized environment | -| [id](#output\_id) | Disambiguated ID restricted to `id_length_limit` characters in total | -| [id\_full](#output\_id\_full) | Disambiguated ID not restricted in length | +| [id](#output\_id) | Disambiguated ID string restricted to `id_length_limit` characters in total | +| [id\_full](#output\_id\_full) | ID string not restricted in length | | [id\_length\_limit](#output\_id\_length\_limit) | The id\_length\_limit actually used to create the ID, with `0` meaning unlimited | | [label\_order](#output\_label\_order) | The naming order actually used to create the ID | | [name](#output\_name) | Normalized name | @@ -753,24 +848,12 @@ No resources. | [regex\_replace\_chars](#output\_regex\_replace\_chars) | The regex\_replace\_chars actually used to create the ID | | [stage](#output\_stage) | Normalized stage | | [tags](#output\_tags) | Normalized Tag map | -| [tags\_as\_list\_of\_maps](#output\_tags\_as\_list\_of\_maps) | Additional tags as a list of maps, which can be used in several AWS resources | +| [tags\_as\_list\_of\_maps](#output\_tags\_as\_list\_of\_maps) | This is a list with one map for each `tag`. Each map contains the tag `key`,
`value`, and contents of `var.additional_tag_map`. Used in the rare cases
where resources need additional configuration information for each tag. | +| [tenant](#output\_tenant) | Normalized tenant | -## Share the Love - -Like this project? Please give it a ★ on [our GitHub](https://github.com/cloudposse/terraform-null-label)! (it helps us **a lot**) - -Are you using this project or any of our other projects? Consider [leaving a testimonial][testimonial]. =) - - - -## Related Projects - -Check out these related projects. - -- [terraform-terraform-label](https://github.com/cloudposse/terraform-terraform-label) - Terraform Module to define a consistent naming convention by (namespace, environment, stage, name, [attributes]) ## Help diff --git a/README.yaml b/README.yaml index 270929e..e24b77c 100644 --- a/README.yaml +++ b/README.yaml @@ -18,30 +18,62 @@ badges: - name: Slack Community image: https://slack.cloudposse.com/badge.svg url: https://slack.cloudposse.com -related: -- name: terraform-terraform-label - description: Terraform Module to define a consistent naming convention by (namespace, - environment, stage, name, [attributes]) - url: https://github.com/cloudposse/terraform-terraform-label description: |- Terraform module designed to generate consistent names and tags for resources. Use `terraform-null-label` to implement a strict naming convention. - This module generates names using the following convention by default: `{namespace}-{environment}-{stage}-{name}-{attributes}`. + There are 6 inputs considered "labels" or "ID elements" (because the labels are used to construct the ID): + 1. namespace + 1. tenant + 1. environment + 1. stage + 1. name + 1. attributes + + This module generates IDs using the following convention by default: `{namespace}-{environment}-{stage}-{name}-{attributes}`. However, it is highly configurable. The delimiter (e.g. `-`) is configurable. Each label item is optional (although you must provide at least one). - So if you prefer the term `stage` to `environment` - you can exclude environment and the label `id` will look like `{namespace}-{stage}-{name}-{attributes}`. - - If attributes are excluded but `namespace`, `stage`, and `environment` are included, `id` will look like `{namespace}-{environment}-{stage}-{name}`. - - If you want the attributes in a different order, you can specify that, too, with the `label_order` list. - - You can set a maximum length for the name, and the module will create a unique name that fits within that length. + So if you prefer the term `stage` to `environment` and do not need `tenant`, you can exclude them + and the label `id` will look like `{namespace}-{stage}-{name}-{attributes}`. + - The `tenant` label was introduced in v0.25.0. To preserve backward compatibility, it is not included by default. + - The `attributes` input is actually a list of strings and `{attributes}` expands to the list elements joined by the delimiter. + - If `attributes` is excluded but `namespace`, `stage`, and `environment` are included, `id` will look like `{namespace}-{environment}-{stage}-{name}`. + Excluding `attributes` is discouraged, though, because attributes are the main way modules modify the ID to ensure uniqueness when provisioning the same resource types. + - If you want the label items in a different order, you can specify that, too, with the `label_order` list. + - You can set a maximum length for the `id`, and the module will create a (probably) unique name that fits within that length. + (The module uses a portion of the MD5 hash of the full `id` to represent the missing part, so there remains a slight chance of name collision.) - You can control the letter case of the generated labels which make up the `id` using `var.label_value_case`. - - The labels are also exported as tags. You can control the case of the tag names (keys) using `var.label_key_case`. + - By default, all of the non-empty labels are also exported as tags, whether they appear in the `id` or not. + You can control which labels are exported as tags by setting `labels_as_tags` to the list of labels you want exported, + or the empty list `[]` if you want no labels exported as tags at all. Tags passed in via the `tags` variable are + always exported, and regardless of settings, empty labels are never exported as tags. + You can control the case of the tag names (keys) for the labels using `var.label_key_case`. + Unlike the tags generated from the label inputs, tags passed in via the `tags` input are not modified. + + There is an unfortunate collision over the use of the key `name`. Cloud Posse uses `name` in this module + to represent the component, such as `eks` or `rds`. AWS uses a tag with the key `Name` to store the full human-friendly + identifier of the thing tagged, which this module outputs as `id`, not `name`. So when converting input labels + to tags, the value of the `Name` key is set to the module `id` output, and there is no tag corresponding to the + module `name` output. An empty `name` label will not prevent the `Name` tag from being exported. It's recommended to use one `terraform-null-label` module for every unique resource of a given resource type. For example, if you have 10 instances, there should be 10 different labels. However, if you have multiple different kinds of resources (e.g. instances, security groups, file systems, and elastic ips), then they can all share the same label assuming they are logically related. - All [Cloud Posse modules](https://github.com/cloudposse?utf8=%E2%9C%93&q=terraform-&type=&language=) use this module to ensure resources can be instantiated multiple times within an account and without conflict. - + For most purposes, the `id` output is sufficient to create an ID or label for a resource, and if you want a different + ID or a different format, you would instantiate another instance of `null-label` and configure it accordingly. However, + to accomodate situations where you want all the same inputs to generate multiple descriptors, this module provides + the `descriptors` output, which is a map of strings generated according to the format specified by the + `descriptor_formats` input. This feature is intentionally simple and minimally configurable and will not be + enhanced to add more features that are already in `null-label`. See [examples/complete/descriptors.tf](examples/complete/descriptors.tf) for examples. + + All [Cloud Posse Terraform modules](https://github.com/cloudposse?utf8=%E2%9C%93&q=terraform-&type=&language=) use this module to ensure resources can be instantiated multiple times within an account and without conflict. + + The Cloud Posse convention is to use labels as follows: + - `namespace`: A short (3-4 letters) abbreviation of the company name, to ensure globally unique IDs for things like S3 buckets + - `tenant`: _(Rarely needed)_ When a company creates a dedicated resource per customer, `tenant` can be used to identify the customer the resource is dedicated to + - `environment`: A [short abbreviation](https://github.com/cloudposse/terraform-aws-utils/#introduction) for the AWS region hosting the resource, or `gbl` for resources like IAM roles that have no region + - `stage`: The name or role of the account the resource is for, such as `prod` or `dev` + - `name`: The name of the component that owns the resources, such as `eks` or `rds` + **NOTE:** The `null` originally referred to the primary Terraform [provider](https://www.terraform.io/docs/providers/null/index.html) used in this module. With Terraform 0.12, this module no longer needs any provider, but the name was kept for continuity. @@ -56,17 +88,22 @@ usage: |- However, each input value can also be specified individually by name as a standard Terraform variable, and the value of those variables, when set to something other than `null`, will override the value in the context object. In order to allow chaining of these objects, where the context object input to one - module is transformed and passed to the next module, all the variables default to `null` or empty collections. - The actual default values used when nothing is explicitly set are describe in the documentation below. + module is transformed and passed on to the next module, all the variables default to `null` or empty collections. + The actual default values used when nothing is explicitly set are described in the documentation below. - For example, the default value of `delimiter` is shown as `null`, but if you leave it set to null, + For example, the default value of `delimiter` is shown as `null`, but if you leave it set to `null`, `terraform-null-label` will actually use the default delimiter `-` (hyphen). A non-obvious but intentional consequence of this design is that once a module sets a non-default value, - future modules in the chain cannot reset the value back to the original default. Insted, the new setting + future modules in the chain cannot reset the value back to the original default. Instead, the new setting becomes the new default for downstream modules. Also, collections are not overwritten, they are merged, so once a tag is added, it will remain in the tag set and cannot be removed, although its value can be overwritten. + + Because the purpose of `labels_as_tags` is primarily to prevent tags from being generated + that would [conflict with the AWS provider's `default_tags`](https://github.com/hashicorp/terraform-provider-aws/issues/19204), it is an exception to the + rule that variables override the setting in the context object. The value in the context + object cannot be changed, so that later modules cannot re-enable a problematic tag. ### Simple Example @@ -74,7 +111,7 @@ usage: |- module "eg_prod_bastion_label" { source = "cloudposse/label/null" # Cloud Posse recommends pinning every module to a specific version - # version = "x.x.x" + # version = "x.x.x" namespace = "eg" stage = "prod" @@ -125,15 +162,14 @@ usage: |-
Click to show ```hcl - module "eg_prod_bastion_abc_label" { + module "eg_prod_bastion_label" { source = "cloudposse/label/null" # Cloud Posse recommends pinning every module to a specific version - # version = "x.x.x" - + # version = "x.x.x" + namespace = "eg" stage = "prod" name = "bastion" - attributes = ["abc"] delimiter = "-" tags = { @@ -141,7 +177,22 @@ usage: |- "Snapshot" = "true" } } + + module "eg_prod_bastion_abc_label" { + source = "cloudposse/label/null" + # Cloud Posse recommends pinning every module to a specific version + # version = "x.x.x" + + attributes = ["abc"] + + tags = { + "BusinessUnit" = "ABC" # Override the Business Unit tag set in the base label + } + # Copy all other fields from the base label + context = module.eg_prod_bastion_label.context + } + resource "aws_security_group" "eg_prod_bastion_abc" { name = module.eg_prod_bastion_abc_label.id tags = module.eg_prod_bastion_abc_label.tags @@ -156,24 +207,17 @@ usage: |- resource "aws_instance" "eg_prod_bastion_abc" { instance_type = "t1.micro" tags = module.eg_prod_bastion_abc_label.tags -   vpc_security_group_ids = [aws_security_group.eg_prod_bastion_abc.id] + vpc_security_group_ids = [aws_security_group.eg_prod_bastion_abc.id] } module "eg_prod_bastion_xyz_label" { source = "cloudposse/label/null" # Cloud Posse recommends pinning every module to a specific version - # version = "x.x.x" + # version = "x.x.x" - namespace = "eg" - stage = "prod" - name = "bastion" attributes = ["xyz"] - delimiter = "-" - - tags = { - "BusinessUnit" = "XYZ", - "Snapshot" = "true" - } + + context = module.eg_prod_bastion_label.context } resource "aws_security_group" "eg_prod_bastion_xyz" { @@ -198,26 +242,27 @@ usage: |- ### Advanced Example 2 - Here is a more complex example with an autoscaling group that has a different tagging schema than other resources and requires its tags to be in this format, which this module can generate: + Here is a more complex example with an autoscaling group that has a different tagging schema than other resources and + requires its tags to be in this format, which this module can generate via `additional_tag_map` and `tags_as_list_of_maps`:
Click to show ```hcl tags = [ { - key = Name, - propagate_at_launch = 1, - value = namespace-stage-name + key = "Name", + propagate_at_launch = true, + value = "namespace-stage-name" }, { - key = Namespace, - propagate_at_launch = 1, - value = namespace + key = "Namespace", + propagate_at_launch = true, + value = "namespace" }, { - key = Stage, - propagate_at_launch = 1, - value = stage + key = "Stage", + propagate_at_launch = true, + value = "stage" } ] ``` @@ -240,7 +285,7 @@ usage: |- } additional_tag_map = { - propagate_at_launch = "true" + propagate_at_launch = true } } @@ -273,12 +318,12 @@ usage: |- # terraform-null-label example used here: Set ASG name prefix name_prefix = "${module.label.id}-" vpc_zone_identifier = data.aws_subnet_ids.all.ids - max_size = "1" - min_size = "1" - desired_capacity = "1" + max_size = 1 + min_size = 1 + desired_capacity = 1 launch_template = { - id = "aws_launch_template.default.id + id = aws_launch_template.default.id version = "$$Latest" } @@ -306,13 +351,13 @@ usage: |- # version = "x.x.x" namespace = "CloudPosse" + tenant = "H.R.H" environment = "UAT" stage = "build" name = "Winston Churchroom" attributes = ["fire", "water", "earth", "air"] - delimiter = "-" - label_order = ["name", "environment", "stage", "attributes"] + label_order = ["name", "tenant", "environment", "stage", "attributes"] tags = { "City" = "Dublin" @@ -325,15 +370,23 @@ usage: |- # Cloud Posse recommends pinning every module to a specific version # version = "x.x.x" - context = module.label1.context name = "Charlie" + tenant = "" # setting to `null` would have no effect stage = "test" delimiter = "+" + regex_replace_chars = "/[^a-zA-Z0-9-+]/" + + additional_tag_map = { + propagate_at_launch = true + additional_tag = "yes" + } tags = { "City" = "London" "Environment" = "Public" } + + context = module.label1.context } module "label3" { @@ -343,13 +396,15 @@ usage: |- name = "Starfish" stage = "release" - context = module.label1.context delimiter = "." + regex_replace_chars = "/[^-a-zA-Z0-9.]/" tags = { "Eat" = "Carrot" "Animal" = "Rabbit" } + + context = module.label1.context } ``` @@ -357,61 +412,71 @@ usage: |- ```hcl label1 = { - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "-" - "id" = "winstonchurchroom-uat-build-fire-water-earth-air" + "id" = "winstonchurchroom-hrh-uat-build-fire-water-earth-air" "name" = "winstonchurchroom" "namespace" = "cloudposse" "stage" = "build" + "tenant" = "hrh" } label1_context = { "additional_tag_map" = {} - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] - "delimiter" = "-" + ]) + "delimiter" = tostring(null) "enabled" = true "environment" = "UAT" - "label_order" = [ + "id_length_limit" = tonumber(null) + "label_key_case" = tostring(null) + "label_order" = tolist([ "name", + "tenant", "environment", "stage", "attributes", - ] + ]) + "label_value_case" = tostring(null) "name" = "Winston Churchroom" "namespace" = "CloudPosse" + "regex_replace_chars" = tostring(null) "stage" = "build" "tags" = { "City" = "Dublin" "Environment" = "Private" } + "tenant" = "H.R.H" } label1_normalized_context = { "additional_tag_map" = {} - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "-" "enabled" = true "environment" = "uat" "id_length_limit" = 0 - "label_order" = [ + "label_key_case" = "title" + "label_order" = tolist([ "name", + "tenant", "environment", "stage", "attributes", - ] + ]) + "label_value_case" = "lower" "name" = "winstonchurchroom" "namespace" = "cloudposse" "regex_replace_chars" = "/[^-a-zA-Z0-9]/" @@ -420,52 +485,60 @@ usage: |- "Attributes" = "fire-water-earth-air" "City" = "Dublin" "Environment" = "Private" - "Name" = "winstonchurchroom-uat-build-fire-water-earth-air" + "Name" = "winstonchurchroom-hrh-uat-build-fire-water-earth-air" "Namespace" = "cloudposse" "Stage" = "build" + "Tenant" = "hrh" } + "tenant" = "hrh" } - label1_tags = { + label1_tags = tomap({ "Attributes" = "fire-water-earth-air" "City" = "Dublin" "Environment" = "Private" - "Name" = "winstonchurchroom-uat-build-fire-water-earth-air" + "Name" = "winstonchurchroom-hrh-uat-build-fire-water-earth-air" "Namespace" = "cloudposse" "Stage" = "build" - } + "Tenant" = "hrh" + }) label2 = { - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "+" "id" = "charlie+uat+test+fire+water+earth+air" "name" = "charlie" "namespace" = "cloudposse" "stage" = "test" + "tenant" = "" } label2_context = { "additional_tag_map" = { "additional_tag" = "yes" "propagate_at_launch" = "true" } - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "+" "enabled" = true "environment" = "UAT" - "label_order" = [ + "id_length_limit" = tonumber(null) + "label_key_case" = tostring(null) + "label_order" = tolist([ "name", + "tenant", "environment", "stage", "attributes", - ] + ]) + "label_value_case" = tostring(null) "name" = "Charlie" "namespace" = "CloudPosse" "regex_replace_chars" = "/[^a-zA-Z0-9-+]/" @@ -474,15 +547,16 @@ usage: |- "City" = "London" "Environment" = "Public" } + "tenant" = "" } - label2_tags = { + label2_tags = tomap({ "Attributes" = "fire+water+earth+air" "City" = "London" "Environment" = "Public" "Name" = "charlie+uat+test+fire+water+earth+air" "Namespace" = "cloudposse" "Stage" = "test" - } + }) label2_tags_as_list_of_maps = [ { "additional_tag" = "yes" @@ -522,35 +596,40 @@ usage: |- }, ] label3 = { - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "." - "id" = "starfish.uat.release.fire.water.earth.air" + "id" = "starfish.h.r.h.uat.release.fire.water.earth.air" "name" = "starfish" "namespace" = "cloudposse" "stage" = "release" + "tenant" = "h.r.h" } label3_context = { "additional_tag_map" = {} - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "." "enabled" = true "environment" = "UAT" - "label_order" = [ + "id_length_limit" = tonumber(null) + "label_key_case" = tostring(null) + "label_order" = tolist([ "name", + "tenant", "environment", "stage", "attributes", - ] + ]) + "label_value_case" = tostring(null) "name" = "Starfish" "namespace" = "CloudPosse" "regex_replace_chars" = "/[^-a-zA-Z0-9.]/" @@ -561,25 +640,29 @@ usage: |- "Eat" = "Carrot" "Environment" = "Private" } + "tenant" = "H.R.H" } label3_normalized_context = { "additional_tag_map" = {} - "attributes" = [ + "attributes" = tolist([ "fire", "water", "earth", "air", - ] + ]) "delimiter" = "." "enabled" = true "environment" = "uat" "id_length_limit" = 0 - "label_order" = [ + "label_key_case" = "title" + "label_order" = tolist([ "name", + "tenant", "environment", "stage", "attributes", - ] + ]) + "label_value_case" = "lower" "name" = "starfish" "namespace" = "cloudposse" "regex_replace_chars" = "/[^-a-zA-Z0-9.]/" @@ -590,21 +673,24 @@ usage: |- "City" = "Dublin" "Eat" = "Carrot" "Environment" = "Private" - "Name" = "starfish.uat.release.fire.water.earth.air" + "Name" = "starfish.h.r.h.uat.release.fire.water.earth.air" "Namespace" = "cloudposse" "Stage" = "release" + "Tenant" = "h.r.h" } + "tenant" = "h.r.h" } - label3_tags = { + label3_tags = tomap({ "Animal" = "Rabbit" "Attributes" = "fire.water.earth.air" "City" = "Dublin" "Eat" = "Carrot" "Environment" = "Private" - "Name" = "starfish.uat.release.fire.water.earth.air" + "Name" = "starfish.h.r.h.uat.release.fire.water.earth.air" "Namespace" = "cloudposse" "Stage" = "release" - } + "Tenant" = "h.r.h" + }) ``` diff --git a/descriptors.tf b/descriptors.tf new file mode 100644 index 0000000..c893c99 --- /dev/null +++ b/descriptors.tf @@ -0,0 +1,28 @@ +# It would be nice to have a fixed array of arguments passed into +# `format()` so all you need to provide is a format string, but +# unfortunately, that does not work easily +# due to https://github.com/hashicorp/terraform/issues/28558 +# which requires that the format string consume the last argument passed in. +# We could hack around it by adding then removing a trailing arg, like +# +# trimsuffix(format("${var.format_string}%[${length(local.labels)+1}]v", concat(local.labels, ["x"])...), "x") +# +# but that is kind of a hack, and overlooks the fact that local.labels +# drops empty label elements, so the index of an element is not guaranteed. +# +# +# So we require the user to specify the arguments as well as the format string. +# + +# There is a lot of room for enhancement, but since this is a new feature +# with only 2 use cases, we are going to keep it simple for now. + +locals { + descriptor_labels = { for k, v in var.descriptor_formats : k => [ + for label in v.labels : local.id_context[label] + ] } + descriptors = { for k, v in var.descriptor_formats : k => ( + format(v.format, local.descriptor_labels[k]...) + ) + } +} diff --git a/docs/terraform.md b/docs/terraform.md index af12903..03da95d 100644 --- a/docs/terraform.md +++ b/docs/terraform.md @@ -21,21 +21,24 @@ No resources. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [additional\_tag\_map](#input\_additional\_tag\_map) | Additional tags for appending to tags\_as\_list\_of\_maps. Not added to `tags`. | `map(string)` | `{}` | no | -| [attributes](#input\_attributes) | Additional attributes (e.g. `1`) | `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,
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"name": null,
"namespace": null,
"regex_replace_chars": null,
"stage": null,
"tags": {}
}
| no | -| [delimiter](#input\_delimiter) | Delimiter to be used between `namespace`, `environment`, `stage`, `name` and `attributes`.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | +| [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 | +| [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 | +| [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) | Environment, e.g. 'uw2', 'us-west-2', OR 'prod', 'staging', 'dev', 'UAT' | `string` | `null` | 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 default, which is `0`.
Does not affect `id_full`. | `number` | `null` | no | -| [label\_key\_case](#input\_label\_key\_case) | The letter case of label keys (`tag` names) (i.e. `name`, `namespace`, `environment`, `stage`, `attributes`) to use in `tags`.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | -| [label\_order](#input\_label\_order) | The naming order of the id output and Name tag.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 5 elements, but at least one must be present. | `list(string)` | `null` | no | -| [label\_value\_case](#input\_label\_value\_case) | The letter case of output label values (also used in `tags` and `id`).
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Default value: `lower`. | `string` | `null` | no | -| [name](#input\_name) | Solution name, e.g. 'app' or 'jenkins' | `string` | `null` | no | -| [namespace](#input\_namespace) | Namespace, which could be your organization name or abbreviation, e.g. 'eg' or 'cp' | `string` | `null` | no | -| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Regex to replace chars with empty string in `namespace`, `environment`, `stage` and `name`.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | -| [stage](#input\_stage) | Stage, e.g. 'prod', 'staging', 'dev', OR 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | no | -| [tags](#input\_tags) | Additional tags (e.g. `map('BusinessUnit','XYZ')` | `map(string)` | `{}` | 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 | +| [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 | +| [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 | +| [stage](#input\_stage) | ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release' | `string` | `null` | 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 | ## Outputs @@ -44,11 +47,12 @@ No resources. | [additional\_tag\_map](#output\_additional\_tag\_map) | The merged additional\_tag\_map | | [attributes](#output\_attributes) | List of attributes | | [context](#output\_context) | Merged but otherwise unmodified input to this module, to be used as context input to other modules.
Note: this version will have null values as defaults, not the values actually used as defaults. | -| [delimiter](#output\_delimiter) | Delimiter between `namespace`, `environment`, `stage`, `name` and `attributes` | +| [delimiter](#output\_delimiter) | Delimiter between `namespace`, `tenant`, `environment`, `stage`, `name` and `attributes` | +| [descriptors](#output\_descriptors) | Map of descriptors as configured by `descriptor_formats` | | [enabled](#output\_enabled) | True if module is enabled, false otherwise | | [environment](#output\_environment) | Normalized environment | -| [id](#output\_id) | Disambiguated ID restricted to `id_length_limit` characters in total | -| [id\_full](#output\_id\_full) | Disambiguated ID not restricted in length | +| [id](#output\_id) | Disambiguated ID string restricted to `id_length_limit` characters in total | +| [id\_full](#output\_id\_full) | ID string not restricted in length | | [id\_length\_limit](#output\_id\_length\_limit) | The id\_length\_limit actually used to create the ID, with `0` meaning unlimited | | [label\_order](#output\_label\_order) | The naming order actually used to create the ID | | [name](#output\_name) | Normalized name | @@ -57,5 +61,6 @@ No resources. | [regex\_replace\_chars](#output\_regex\_replace\_chars) | The regex\_replace\_chars actually used to create the ID | | [stage](#output\_stage) | Normalized stage | | [tags](#output\_tags) | Normalized Tag map | -| [tags\_as\_list\_of\_maps](#output\_tags\_as\_list\_of\_maps) | Additional tags as a list of maps, which can be used in several AWS resources | +| [tags\_as\_list\_of\_maps](#output\_tags\_as\_list\_of\_maps) | This is a list with one map for each `tag`. Each map contains the tag `key`,
`value`, and contents of `var.additional_tag_map`. Used in the rare cases
where resources need additional configuration information for each tag. | +| [tenant](#output\_tenant) | Normalized tenant | diff --git a/examples/complete/.gitignore b/examples/complete/.gitignore index 3e4e865..4b3d0d8 100644 --- a/examples/complete/.gitignore +++ b/examples/complete/.gitignore @@ -1,4 +1,5 @@ .terraform +.terraform.lock.hcl **/.terraform/* *.tfstate *.tfstate.* diff --git a/examples/complete/compatibility.tf b/examples/complete/compatibility.tf new file mode 100644 index 0000000..423872c --- /dev/null +++ b/examples/complete/compatibility.tf @@ -0,0 +1,286 @@ +#### +# these tests ensure that new versions of null-label remain compatible and +# interoperable with old versions of null-label. +# +# However, there is a known incompatibility we are not going to do anything about: +# +# The input regex_replace_chars specifies a regular expression, and characters matching it are removed +# from labels/id elements. Prior to this release, if the delimiter itself matched the regular expression, +# then the delimiter would be removed from the attributes portion of the id. This was not a problem +# for most users, since the default delimiter was - (dash) and the default regex allowed dashes, but +# if you customized the delimiter and/or regex, it mattered. So these +# compatibility tests are required to allow the delimiter in the labels. + +module "source_v22_full" { + source = "cloudposse/label/null" + version = "0.22.1" + + enabled = true + namespace = "CloudPosse" + environment = "UAT" + stage = "build" + name = "Winston Churchroom" + delimiter = "+" + attributes = ["fire", "water"] + + tags = { + City = "Dublin" + Environment = "Private" + } + additional_tag_map = { + propagate = true + } + label_order = ["name", "environment", "stage", "attributes"] + regex_replace_chars = "/[^a-tv-zA-Z0-9+]/" # Eliminate "u" just to verify this is taking effect + id_length_limit = 28 +} + +module "source_v22_empty" { + source = "cloudposse/label/null" + version = "0.22.1" + + stage = "STAGE" +} + +module "source_v24_full" { + source = "cloudposse/label/null" + version = "0.24.1" + + enabled = true + namespace = "CloudPosse" + environment = "UAT" + stage = "build" + name = "Winston Churchroom" + delimiter = "+" + attributes = ["fire", "water"] + + tags = { + City = "Dublin" + Environment = "Private" + } + additional_tag_map = { + propagate = true + } + label_order = ["name", "environment", "stage", "attributes"] + regex_replace_chars = "/[^a-tv-zA-Z0-9+]/" # Eliminate "u" just to verify this is taking effect + id_length_limit = 28 + + label_key_case = "upper" + label_value_case = "lower" +} + +module "source_v24_empty" { + source = "cloudposse/label/null" + version = "0.24.1" + + stage = "STAGE" +} + +# When testing the backward compatibility of supplying a new +# context to an old module, it is not fair to use +# the new features in the new module. +module "source_v25_22_full" { + source = "../.." + + enabled = true + namespace = "CloudPosse" + environment = "UAT" + stage = "build" + name = "Winston Churchroom" + delimiter = "+" + attributes = ["fire", "water"] + + tags = { + City = "Dublin" + Environment = "Private" + } + additional_tag_map = { + propagate = true + } + label_order = ["name", "environment", "stage", "attributes"] + # Need to add "+" to the regex in v0.22.1 due to a known issue: + # the attributes string will have the delimiter stripped out + # if the delimiter is selected by `regex_replace_chars`. + # This was fixed in v0.24.1 + regex_replace_chars = "/[^a-tv-zA-Z0-9+]/" # Eliminate "u" just to verify this is taking effect + id_length_limit = 28 +} + +module "source_v25_24_full" { + source = "../.." + regex_replace_chars = "/[^a-tv-zA-Z0-9]/" # Eliminate "u" just to verify this is taking effect + + label_key_case = "lower" + label_value_case = "upper" + + context = module.source_v25_22_full.context +} + +module "source_v25_empty" { + source = "../.." + + stage = "STAGE" +} + +module "compat_22_25_full" { + source = "../.." + context = module.source_v22_full.context +} + +module "compat_24_25_full" { + source = "../.." + context = module.source_v24_full.context +} + +module "compat_22_25_empty" { + source = "../.." + context = module.source_v22_empty.context +} + +module "compat_24_25_empty" { + source = "../.." + context = module.source_v24_empty.context +} + +module "compat_25_22_full" { + source = "cloudposse/label/null" + version = "0.22.1" + + # Known issue, additional_tag_map not taken from context + additional_tag_map = module.source_v25_22_full.context.additional_tag_map + + context = module.source_v25_22_full.context +} + +module "compat_25_24_full" { + source = "cloudposse/label/null" + version = "0.24.1" + + # Known issue, additional_tag_map not taken from context + additional_tag_map = module.source_v25_22_full.context.additional_tag_map + + context = module.source_v25_24_full.context +} + +module "compat_25_22_empty" { + source = "cloudposse/label/null" + version = "0.22.1" + + context = module.source_v25_empty.context +} + +module "compat_25_24_empty" { + source = "cloudposse/label/null" + version = "0.24.1" + + context = module.source_v25_empty.context +} + +module "compare_22_25_full" { + source = "./module/compare" + a = module.source_v22_full + b = module.compat_22_25_full +} + +output "compare_22_25_full" { + value = module.compare_22_25_full +} + +/* Uncomment this code to see how the fields differ +output "source_22_full_id_full" { + value = module.source_v22_full.id_full +} +output "compat_22_25_full_id_full" { + value = module.compat_22_25_full.id_full +} +output "source_22_full_talm" { + value = module.source_v22_full.tags_as_list_of_maps +} +output "compat_22_25_full_talm" { + value = module.compat_22_25_full.tags_as_list_of_maps +} +*/ + +module "compare_24_25_full" { + source = "./module/compare" + a = module.source_v24_full + b = module.compat_24_25_full +} + +output "compare_24_25_full" { + value = module.compare_24_25_full +} + +module "compare_22_25_empty" { + source = "./module/compare" + a = module.source_v22_empty + b = module.compat_22_25_empty +} + +output "compare_22_25_empty" { + value = module.compare_22_25_empty +} + +module "compare_24_25_empty" { + source = "./module/compare" + a = module.source_v24_empty + b = module.compat_24_25_empty +} + +output "compare_24_25_empty" { + value = module.compare_24_25_empty +} + +module "compare_25_22_full" { + source = "./module/compare" + a = module.source_v25_22_full + b = module.compat_25_22_full +} + +output "compare_25_22_full" { + value = module.compare_25_22_full +} + +module "compare_25_24_full" { + source = "./module/compare" + a = module.source_v25_24_full + b = module.compat_25_24_full +} + +output "compare_25_24_full" { + value = module.compare_25_24_full +} + +module "compare_25_22_empty" { + source = "./module/compare" + a = module.source_v25_empty + b = module.compat_25_22_empty +} + +output "compare_25_22_empty" { + value = module.compare_25_22_empty +} + +module "compare_25_24_empty" { + source = "./module/compare" + a = module.source_v25_empty + b = module.compat_25_24_empty +} + +output "compare_25_24_empty" { + value = module.compare_25_24_empty +} + + +output "compatible" { + value = ( + module.compare_22_25_full.equal && + module.compare_24_25_full.equal && + module.compare_25_22_full.equal && + module.compare_25_24_full.equal && + module.compare_22_25_empty.equal && + module.compare_24_25_empty.equal && + module.compare_25_22_empty.equal && + module.compare_25_24_empty.equal + ) +} \ No newline at end of file diff --git a/examples/complete/context.tf b/examples/complete/context.tf index 973d4dc..5f964d1 100644 --- a/examples/complete/context.tf +++ b/examples/complete/context.tf @@ -1,8 +1,3 @@ -# DO NOT COPY THIS FILE -# -# This is a specially modified version of this file, since it is used to test -# the unpublished version of this module. Normally you should use a -# copy of the file as explained below. # # ONLY EDIT THIS FILE IN github.com/cloudposse/terraform-null-label # All other instances of this file should be a copy of that one @@ -13,6 +8,8 @@ # 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`, @@ -28,6 +25,7 @@ module "this" { enabled = var.enabled namespace = var.namespace + tenant = var.tenant environment = var.environment stage = var.stage name = var.name @@ -40,6 +38,8 @@ module "this" { 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 } @@ -47,25 +47,11 @@ module "this" { # Copy contents of cloudposse/terraform-null-label/variables.tf here variable "context" { - type = object({ - enabled = bool - namespace = string - environment = string - stage = string - name = string - delimiter = string - attributes = list(string) - tags = map(string) - additional_tag_map = map(string) - regex_replace_chars = string - label_order = list(string) - id_length_limit = number - label_key_case = string - label_value_case = string - }) + type = any default = { enabled = true namespace = null + tenant = null environment = null stage = null name = null @@ -78,6 +64,15 @@ variable "context" { 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. @@ -88,12 +83,12 @@ variable "context" { EOT validation { - condition = var.context["label_key_case"] == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) + 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 = var.context["label_value_case"] == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) + 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`." } } @@ -107,32 +102,42 @@ variable "enabled" { variable "namespace" { type = string default = null - description = "Namespace, which could be your organization name or abbreviation, e.g. 'eg' or 'cp'" + 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 = "Environment, e.g. 'uw2', 'us-west-2', OR 'prod', 'staging', 'dev', 'UAT'" + 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 = "Stage, e.g. 'prod', 'staging', 'dev', OR 'source', 'build', 'test', 'deploy', 'release'" + description = "ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release'" } variable "name" { type = string default = null - description = "Solution name, e.g. 'app' or 'jenkins'" + 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 `namespace`, `environment`, `stage`, `name` and `attributes`. + Delimiter to be used between ID elements. Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. EOT } @@ -140,36 +145,64 @@ variable "delimiter" { variable "attributes" { type = list(string) default = [] - description = "Additional attributes (e.g. `1`)" + 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 = "Additional tags (e.g. `map('BusinessUnit','XYZ')`" + 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 = "Additional tags for appending to tags_as_list_of_maps. Not added to `tags`." + 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 naming order of the id output and Name tag. + 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 5 elements, but at least one must be present. - EOT + 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 - Regex to replace chars with empty string in `namespace`, `environment`, `stage` and `name`. + 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 } @@ -178,18 +211,23 @@ variable "id_length_limit" { type = number default = null description = <<-EOT - Limit `id` to this many characters. + Limit `id` to this many characters (minimum 6). Set to `0` for unlimited length. - Set to `null` for default, which is `0`. + 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 - The letter case of label keys (`tag` names) (i.e. `name`, `namespace`, `environment`, `stage`, `attributes`) to use in `tags`. + 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 @@ -204,8 +242,11 @@ variable "label_value_case" { type = string default = null description = <<-EOT - The letter case of output label values (also used in `tags` and `id`). + 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 @@ -214,4 +255,24 @@ variable "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/examples/complete/descriptors.tf b/examples/complete/descriptors.tf new file mode 100644 index 0000000..0795d5d --- /dev/null +++ b/examples/complete/descriptors.tf @@ -0,0 +1,42 @@ +module "descriptors" { + source = "../.." + + enabled = true + tenant = "H.R.H" + namespace = "CloudPosse" + environment = "UAT" + stage = "build" + name = "Winston Churchroom" + delimiter = "+" + attributes = ["fire", "water"] + + tags = { + City = "Dublin" + Environment = "Private" + } + additional_tag_map = { + propagate = true + } + label_order = ["name", "environment", "stage", "attributes"] + regex_replace_chars = "/[^a-tv-zA-Z0-9+]/" # Eliminate "u" just to verify this is taking effect + id_length_limit = 6 + + descriptor_formats = { + stack = { + labels = ["tenant", "environment", "stage"] + format = "%v-%v-%v" + } + account_name = { + labels = ["stage", "tenant"] + format = "%v-%v" + } + } +} + +output "descriptor_stack" { + value = module.descriptors.descriptors["stack"] +} + +output "descriptor_account_name" { + value = module.descriptors.descriptors["account_name"] +} diff --git a/examples/complete/label1.tf b/examples/complete/label1.tf index 8da353b..bdfd6bd 100644 --- a/examples/complete/label1.tf +++ b/examples/complete/label1.tf @@ -1,13 +1,13 @@ module "label1" { source = "../../" namespace = "CloudPosse" + tenant = "H.R.H" environment = "UAT" stage = "build" name = "Winston Churchroom" attributes = ["fire", "water", "earth", "air"] - delimiter = "-" - label_order = ["name", "environment", "stage", "attributes"] + label_order = ["name", "tenant", "environment", "stage", "attributes"] tags = { "City" = "Dublin" @@ -21,6 +21,7 @@ output "label1" { name = module.label1.name namespace = module.label1.namespace stage = module.label1.stage + tenant = module.label1.tenant attributes = module.label1.attributes delimiter = module.label1.delimiter } diff --git a/examples/complete/label1t1.tf b/examples/complete/label1t1.tf index 2bcb362..5ce405a 100644 --- a/examples/complete/label1t1.tf +++ b/examples/complete/label1t1.tf @@ -1,7 +1,7 @@ module "label1t1" { source = "../../" - id_length_limit = 28 + id_length_limit = 32 context = module.label1.context } diff --git a/examples/complete/label1t2.tf b/examples/complete/label1t2.tf index 5df651e..32d7874 100644 --- a/examples/complete/label1t2.tf +++ b/examples/complete/label1t2.tf @@ -1,7 +1,7 @@ module "label1t2" { source = "../../" - id_length_limit = 29 + id_length_limit = 33 context = module.label1.context } diff --git a/examples/complete/label2.tf b/examples/complete/label2.tf index faf7450..5394e31 100644 --- a/examples/complete/label2.tf +++ b/examples/complete/label2.tf @@ -2,20 +2,23 @@ module "label2" { source = "../../" context = module.label1.context name = "Charlie" + tenant = "" # setting to `null` would have no effect stage = "test" delimiter = "+" regex_replace_chars = "/[^a-zA-Z0-9-+]/" additional_tag_map = { - propagate_at_launch = "true" + propagate_at_launch = true additional_tag = "yes" } - tags = { "City" = "London" "Environment" = "Public" } + + # Because this is chained from label1, labels_as_tags should have no effect + labels_as_tags = ["stage"] } output "label2" { @@ -24,6 +27,7 @@ output "label2" { name = module.label2.name namespace = module.label2.namespace stage = module.label2.stage + tenant = module.label2.tenant attributes = module.label2.attributes delimiter = module.label2.delimiter } diff --git a/examples/complete/label3c.tf b/examples/complete/label3c.tf index 338a636..b4b2dc1 100644 --- a/examples/complete/label3c.tf +++ b/examples/complete/label3c.tf @@ -18,6 +18,7 @@ output "label3c" { name = module.label3c.name namespace = module.label3c.namespace stage = module.label3c.stage + tenant = module.label3c.tenant attributes = module.label3c.attributes delimiter = module.label3c.delimiter } diff --git a/examples/complete/label3n.tf b/examples/complete/label3n.tf index ca51282..71832cc 100644 --- a/examples/complete/label3n.tf +++ b/examples/complete/label3n.tf @@ -18,6 +18,7 @@ output "label3n" { name = module.label3n.name namespace = module.label3n.namespace stage = module.label3n.stage + tenant = module.label3n.tenant attributes = module.label3n.attributes delimiter = module.label3n.delimiter } diff --git a/examples/complete/label8d.tf b/examples/complete/label8d.tf index 4252419..1d99f40 100644 --- a/examples/complete/label8d.tf +++ b/examples/complete/label8d.tf @@ -4,13 +4,28 @@ module "label8d" { enabled = true namespace = "eg" environment = "demo" - name = "blue" - attributes = ["cluster"] - delimiter = "-" + # Verify that an empty "name" will not suppress the "Name" tag + tenant = "blue" + attributes = ["cluster"] + delimiter = "-" tags = { "kubernetes.io/cluster/" = "shared" } + + label_order = ["namespace", "environment", "tenant", "attributes"] + + # Verify an empty "stage" label will not be exported as a tag + labels_as_tags = ["environment", "name", "attributes", "stage"] +} + +module "label8d_chained" { + source = "../../" + + # Override should fail, should get same tags as label8d + labels_as_tags = ["namespace"] + + context = module.label8d.context } module "label8d_context" { @@ -42,3 +57,7 @@ output "label8d_context" { output "label8d_tags" { value = module.label8d.tags } + +output "label8d_chained_context_labels_as_tags" { + value = join("-", sort(tolist(module.label8d_chained.context.labels_as_tags))) +} \ No newline at end of file diff --git a/examples/complete/module/compare/compare.tf b/examples/complete/module/compare/compare.tf new file mode 100644 index 0000000..0faa379 --- /dev/null +++ b/examples/complete/module/compare/compare.tf @@ -0,0 +1,48 @@ +# This module compares the outputs of 2 instances of null-label and determines +# whether or not they are equivalent. Used to detect when changes to new +# versions cause an unintended difference in output/behavior +# that would break compatibility. + + +variable "a" { + type = any +} + +variable "b" { + type = any +} + +locals { + equal_id = var.a.id == var.b.id + equal_id_full = var.a.id_full == var.b.id_full + equal_tags_as_list_of_maps = jsonencode(var.a.tags_as_list_of_maps) == jsonencode(var.b.tags_as_list_of_maps) + equal = local.equal_id && local.equal_id_full && local.equal_normalized_context && local.equal_tags_as_list_of_maps + + context_keys = setintersection(keys(var.a.normalized_context), keys(var.b.normalized_context)) + a_context_compare = { for k in local.context_keys : k => var.a.normalized_context[k] } + b_context_compare = { for k in local.context_keys : k => var.b.normalized_context[k] } + equal_normalized_context = jsonencode(local.a_context_compare) == jsonencode(local.b_context_compare) + +} + +output "equal" { + value = local.equal +} + +output "equal_id" { + value = local.equal_id +} + +output "equal_id_full" { + value = local.equal_id_full +} + +output "equal_normalized_context" { + value = local.equal_normalized_context +} + +output "equal_tags_as_list_of_maps" { + value = local.equal_tags_as_list_of_maps +} + + diff --git a/exports/context.tf b/exports/context.tf index 81f99b4..5e0ef88 100644 --- a/exports/context.tf +++ b/exports/context.tf @@ -8,6 +8,8 @@ # 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`, @@ -20,10 +22,11 @@ module "this" { source = "cloudposse/label/null" - version = "0.24.1" # requires Terraform >= 0.13.0 + 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 @@ -36,6 +39,8 @@ module "this" { 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 } @@ -47,6 +52,7 @@ variable "context" { default = { enabled = true namespace = null + tenant = null environment = null stage = null name = null @@ -59,6 +65,15 @@ variable "context" { 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. @@ -88,32 +103,42 @@ variable "enabled" { variable "namespace" { type = string default = null - description = "Namespace, which could be your organization name or abbreviation, e.g. 'eg' or 'cp'" + 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 = "Environment, e.g. 'uw2', 'us-west-2', OR 'prod', 'staging', 'dev', 'UAT'" + 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 = "Stage, e.g. 'prod', 'staging', 'dev', OR 'source', 'build', 'test', 'deploy', 'release'" + description = "ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release'" } variable "name" { type = string default = null - description = "Solution name, e.g. 'app' or 'jenkins'" + 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 `namespace`, `environment`, `stage`, `name` and `attributes`. + Delimiter to be used between ID elements. Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. EOT } @@ -121,36 +146,64 @@ variable "delimiter" { variable "attributes" { type = list(string) default = [] - description = "Additional attributes (e.g. `1`)" + 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 = "Additional tags (e.g. `map('BusinessUnit','XYZ')`" + 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 = "Additional tags for appending to tags_as_list_of_maps. Not added to `tags`." + 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 naming order of the id output and Name tag. + 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 5 elements, but at least one must be present. - EOT + 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 - Regex to replace chars with empty string in `namespace`, `environment`, `stage` and `name`. + 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 } @@ -161,7 +214,7 @@ variable "id_length_limit" { description = <<-EOT Limit `id` to this many characters (minimum 6). Set to `0` for unlimited length. - Set to `null` for default, which is `0`. + Set to `null` for keep the existing setting, which defaults to `0`. Does not affect `id_full`. EOT validation { @@ -174,7 +227,8 @@ variable "label_key_case" { type = string default = null description = <<-EOT - The letter case of label keys (`tag` names) (i.e. `name`, `namespace`, `environment`, `stage`, `attributes`) to use in `tags`. + 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 @@ -189,8 +243,11 @@ variable "label_value_case" { type = string default = null description = <<-EOT - The letter case of output label values (also used in `tags` and `id`). + 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 @@ -199,4 +256,24 @@ variable "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/main.tf b/main.tf index 0deda2b..0499c23 100644 --- a/main.tf +++ b/main.tf @@ -1,6 +1,9 @@ locals { defaults = { + # The `tenant` label was introduced in v0.25.0. To preserve backward compatibility, or, really, to ensure + # that people using the `tenant` label are alerted that it was not previously supported if they try to + # use it in an older version, it is not included by default. label_order = ["namespace", "environment", "stage", "name", "attributes"] regex_replace_chars = "/[^-a-zA-Z0-9]/" delimiter = "-" @@ -9,8 +12,30 @@ locals { id_hash_length = 5 label_key_case = "title" label_value_case = "lower" + + # The default value of labels_as_tags cannot be included in this + # defaults` map because it creates a circular dependency } + default_labels_as_tags = keys(local.tags_context) + # Unlike other inputs, the first setting of `labels_as_tags` cannot be later overridden. However, + # we still have to pass the `input` map as the context to the next module. So we need to distinguish + # between the first setting of var.labels_as_tags == null as meaning set the default and do not change + # it later, versus later settings of var.labels_as_tags that should be ignored. So, we make the + # default value in context be "unset", meaning it can be changed, but when it is unset and + # var.labels_as_tags is null, we change it to "default". Once it is set to "default" we will + # not allow it to be changed again, but of course we have to detect "default" and replace it + # with local.default_labels_as_tags when we go to use it. + # + # We do not want to use null as default or unset, because Terraform has issues with + # the value of an object field being null in some places and [] in others. + # We do not want to use [] as default or unset because that is actually a valid setting + # that we want to have override the default. + # + # To determine whether that context.labels_as_tags is not set, + # we have to cover 2 cases: 1) context does not have a labels_as_tags key, 2) it is present and set to ["unset"] + context_labels_as_tags_is_unset = try(contains(var.context.labels_as_tags, "unset"), true) + # So far, we have decided not to allow overriding replacement or id_hash_length replacement = local.defaults.replacement id_hash_length = local.defaults.id_hash_length @@ -20,8 +45,10 @@ locals { input = { # It would be nice to use coalesce here, but we cannot, because it # is an error for all the arguments to coalesce to be empty. - enabled = var.enabled == null ? var.context.enabled : var.enabled - namespace = var.namespace == null ? var.context.namespace : var.namespace + enabled = var.enabled == null ? var.context.enabled : var.enabled + namespace = var.namespace == null ? var.context.namespace : var.namespace + # tenant was introduced in v0.25.0, prior context versions do not have it + tenant = var.tenant == null ? lookup(var.context, "tenant", null) : var.tenant environment = var.environment == null ? var.context.environment : var.environment stage = var.stage == null ? var.context.stage : var.stage name = var.name == null ? var.context.name : var.name @@ -36,6 +63,9 @@ locals { id_length_limit = var.id_length_limit == null ? var.context.id_length_limit : var.id_length_limit label_key_case = var.label_key_case == null ? lookup(var.context, "label_key_case", null) : var.label_key_case label_value_case = var.label_value_case == null ? lookup(var.context, "label_value_case", null) : var.label_value_case + + descriptor_formats = merge(lookup(var.context, "descriptor_formats", {}), var.descriptor_formats) + labels_as_tags = local.context_labels_as_tags_is_unset ? var.labels_as_tags : var.context.labels_as_tags } @@ -43,7 +73,7 @@ locals { regex_replace_chars = coalesce(local.input.regex_replace_chars, local.defaults.regex_replace_chars) # string_label_names are names of inputs that are strings (not list of strings) used as labels - string_label_names = ["name", "namespace", "environment", "stage"] + string_label_names = ["namespace", "tenant", "environment", "stage", "name"] normalized_labels = { for k in local.string_label_names : k => local.input[k] == null ? "" : replace(local.input[k], local.regex_replace_chars, local.replacement) } @@ -60,10 +90,11 @@ locals { local.label_value_case == "upper" ? upper(v) : lower(v)) ])) - name = local.formatted_labels["name"] namespace = local.formatted_labels["namespace"] + tenant = local.formatted_labels["tenant"] environment = local.formatted_labels["environment"] stage = local.formatted_labels["stage"] + name = local.formatted_labels["name"] delimiter = local.input.delimiter == null ? local.defaults.delimiter : local.input.delimiter label_order = local.input.label_order == null ? local.defaults.label_order : coalescelist(local.input.label_order, local.defaults.label_order) @@ -71,6 +102,12 @@ locals { label_key_case = local.input.label_key_case == null ? local.defaults.label_key_case : local.input.label_key_case label_value_case = local.input.label_value_case == null ? local.defaults.label_value_case : local.input.label_value_case + # labels_as_tags is an exception to the rule that input vars override context values (see above) + labels_as_tags = contains(local.input.labels_as_tags, "default") ? local.default_labels_as_tags : local.input.labels_as_tags + + # Just for standardization and completeness + descriptor_formats = local.input.descriptor_formats + additional_tag_map = merge(var.context.additional_tag_map, var.additional_tag_map) tags = merge(local.generated_tags, local.input.tags) @@ -80,30 +117,32 @@ locals { { key = key value = local.tags[key] - }, var.additional_tag_map) + }, local.additional_tag_map) ]) tags_context = { - # For AWS we need `Name` to be disambiguated since it has a special meaning - name = local.id namespace = local.namespace + tenant = local.tenant environment = local.environment stage = local.stage - attributes = local.id_context.attributes + # For AWS we need `Name` to be disambiguated since it has a special meaning + name = local.id + attributes = local.id_context.attributes } generated_tags = { - for l in keys(local.tags_context) : + for l in setintersection(keys(local.tags_context), local.labels_as_tags) : local.label_key_case == "upper" ? upper(l) : ( local.label_key_case == "lower" ? lower(l) : title(lower(l)) ) => local.tags_context[l] if length(local.tags_context[l]) > 0 } id_context = { - name = local.name namespace = local.namespace + tenant = local.tenant environment = local.environment stage = local.stage + name = local.name attributes = join(local.delimiter, local.attributes) } @@ -117,6 +156,8 @@ locals { # Truncate the ID and ensure a single (not double) trailing delimiter id_truncated = local.id_truncated_length_limit <= 0 ? "" : "${trimsuffix(substr(local.id_full, 0, local.id_truncated_length_limit), local.delimiter)}${local.delimiter}" # Support usages that disallow numeric characters. Would prefer tr 0-9 q-z but Terraform does not support it. + # Probably would have been better to take the hash of only the characters being removed, + # so identical removed strings would produce identical hashes, but it is not worth breaking existing IDs for. id_hash_plus = "${md5(local.id_full)}qrstuvwxyz" id_hash_case = local.label_value_case == "title" ? title(local.id_hash_plus) : local.label_value_case == "upper" ? upper(local.id_hash_plus) : local.label_value_case == "lower" ? lower(local.id_hash_plus) : local.id_hash_plus id_hash = replace(local.id_hash_case, local.regex_replace_chars, local.replacement) @@ -128,10 +169,11 @@ locals { # Context of this label to pass to other label modules output_context = { enabled = local.enabled - name = local.name namespace = local.namespace + tenant = local.tenant environment = local.environment stage = local.stage + name = local.name delimiter = local.delimiter attributes = local.attributes tags = local.tags @@ -141,6 +183,8 @@ locals { id_length_limit = local.id_length_limit label_key_case = local.label_key_case label_value_case = local.label_value_case + labels_as_tags = local.labels_as_tags + descriptor_formats = local.descriptor_formats } } diff --git a/outputs.tf b/outputs.tf index 87ad1be..53ab73d 100644 --- a/outputs.tf +++ b/outputs.tf @@ -1,11 +1,11 @@ output "id" { value = local.enabled ? local.id : "" - description = "Disambiguated ID restricted to `id_length_limit` characters in total" + description = "Disambiguated ID string restricted to `id_length_limit` characters in total" } output "id_full" { value = local.enabled ? local.id_full : "" - description = "Disambiguated ID not restricted in length" + description = "ID string not restricted in length" } output "enabled" { @@ -18,6 +18,11 @@ output "namespace" { description = "Normalized namespace" } +output "tenant" { + value = local.enabled ? local.tenant : "" + description = "Normalized tenant" +} + output "environment" { value = local.enabled ? local.environment : "" description = "Normalized environment" @@ -35,7 +40,7 @@ output "stage" { output "delimiter" { value = local.enabled ? local.delimiter : "" - description = "Delimiter between `namespace`, `environment`, `stage`, `name` and `attributes`" + description = "Delimiter between `namespace`, `tenant`, `environment`, `stage`, `name` and `attributes`" } output "attributes" { @@ -70,7 +75,16 @@ output "id_length_limit" { output "tags_as_list_of_maps" { value = local.tags_as_list_of_maps - description = "Additional tags as a list of maps, which can be used in several AWS resources" + description = <<-EOT + This is a list with one map for each `tag`. Each map contains the tag `key`, + `value`, and contents of `var.additional_tag_map`. Used in the rare cases + where resources need additional configuration information for each tag. + EOT +} + +output "descriptors" { + value = local.descriptors + description = "Map of descriptors as configured by `descriptor_formats`" } output "normalized_context" { diff --git a/test/src/examples_complete_test.go b/test/src/examples_complete_test.go index 156e293..53f0a68 100644 --- a/test/src/examples_complete_test.go +++ b/test/src/examples_complete_test.go @@ -21,6 +21,7 @@ type NLContext struct { RegexReplaceChars interface{} `json:"regex_replace_chars"` Stage interface{} `json:"stage"` Tags map[string]string `json:"tags"` + Tenant interface{} `json:"tenant"` } // Test the Terraform module in examples/complete using Terratest. @@ -39,15 +40,24 @@ func TestExamplesComplete(t *testing.T) { // This will run `terraform init` and `terraform apply` and fail the test if there are any errors terraform.InitAndApply(t, terraformOptions) + compatible := terraform.Output(t, terraformOptions, "compatible") + assert.Equal(t, "true", compatible) + + descriptorAccountName := terraform.Output(t, terraformOptions, "descriptor_account_name") + descriptorStack := terraform.Output(t, terraformOptions, "descriptor_stack") + assert.Equal(t, "bild-hrh", descriptorAccountName) + assert.Equal(t, "hrh-uat-bild", descriptorStack) + expectedLabel1Context := NLContext{ Enabled: true, Namespace: "CloudPosse", + Tenant: "H.R.H", Environment: "UAT", Stage: "build", Name: "Winston Churchroom", Attributes: []string{"fire", "water", "earth", "air"}, - Delimiter: "-", - LabelOrder: []string{"name", "environment", "stage", "attributes"}, + Delimiter: nil, + LabelOrder: []string{"name", "tenant", "environment", "stage", "attributes"}, Tags: map[string]string{ "City": "Dublin", "Environment": "Private", @@ -58,15 +68,18 @@ func TestExamplesComplete(t *testing.T) { var expectedLabel1NormalizedContext NLContext _ = reprint.FromTo(&expectedLabel1Context, &expectedLabel1NormalizedContext) expectedLabel1NormalizedContext.Namespace = "cloudposse" + expectedLabel1NormalizedContext.Tenant = "hrh" expectedLabel1NormalizedContext.Environment = "uat" expectedLabel1NormalizedContext.Name = "winstonchurchroom" + expectedLabel1NormalizedContext.Delimiter = "-" expectedLabel1NormalizedContext.RegexReplaceChars = "/[^-a-zA-Z0-9]/" expectedLabel1NormalizedContext.Tags = map[string]string{ "City": "Dublin", "Environment": "Private", "Namespace": "cloudposse", "Stage": "build", - "Name": "winstonchurchroom-uat-build-fire-water-earth-air", + "Tenant": "hrh", + "Name": "winstonchurchroom-hrh-uat-build-fire-water-earth-air", "Attributes": "fire-water-earth-air", } @@ -78,8 +91,8 @@ func TestExamplesComplete(t *testing.T) { terraform.OutputStruct(t, terraformOptions, "label1_context", &label1Context) // Verify we're getting back the outputs we expect - assert.Equal(t, "winstonchurchroom-uat-build-fire-water-earth-air", label1["id"]) - assert.Equal(t, "winstonchurchroom-uat-build-fire-water-earth-air", label1Tags["Name"]) + assert.Equal(t, "winstonchurchroom-hrh-uat-build-fire-water-earth-air", label1["id"]) + assert.Equal(t, "winstonchurchroom-hrh-uat-build-fire-water-earth-air", label1Tags["Name"]) assert.Equal(t, "Dublin", label1Tags["City"]) assert.Equal(t, "Private", label1Tags["Environment"]) assert.Equal(t, expectedLabel1NormalizedContext, label1NormalizedContext) @@ -87,14 +100,14 @@ func TestExamplesComplete(t *testing.T) { label1t1 := terraform.OutputMap(t, terraformOptions, "label1t1") label1t1Tags := terraform.OutputMap(t, terraformOptions, "label1t1_tags") - assert.Equal(t, "winstonchurchroom-uat-6a0b34", label1t1["id"], + assert.Equal(t, "winstonchurchroom-hrh-uat-6403d8", label1t1["id"], "Extra hash character should be added when trailing delimiter is removed") assert.Equal(t, label1["id"], label1t1["id_full"], "id_full should not be truncated") assert.Equal(t, label1t1["id"], label1t1Tags["Name"], "Name tag should match ID") label1t2 := terraform.OutputMap(t, terraformOptions, "label1t2") label1t2Tags := terraform.OutputMap(t, terraformOptions, "label1t2_tags") - assert.Equal(t, "winstonchurchroom-uat-b-6a0b3", label1t2["id"]) + assert.Equal(t, "winstonchurchroom-hrh-uat-b-6403d", label1t2["id"]) assert.Equal(t, label1t2["id"], label1t2Tags["Name"], "Name tag should match ID") // Run `terraform output` to get the value of an output variable @@ -122,8 +135,8 @@ func TestExamplesComplete(t *testing.T) { terraform.OutputStruct(t, terraformOptions, "label3c_context", &label3cContext) // Verify we're getting back the outputs we expect - assert.Equal(t, "starfish.uat.release.fire.water.earth.air", label3c["id"]) - assert.Equal(t, "starfish.uat.release.fire.water.earth.air", label3cTags["Name"]) + assert.Equal(t, "starfish.h.r.h.uat.release.fire.water.earth.air", label3c["id"]) + assert.Equal(t, "starfish.h.r.h.uat.release.fire.water.earth.air", label3cTags["Name"]) assert.Equal(t, expectedLabel3cContext, label3cContext) var expectedLabel3nContext, label3nContext NLContext @@ -141,7 +154,8 @@ func TestExamplesComplete(t *testing.T) { terraform.OutputStruct(t, terraformOptions, "label3n_context", &label3nContext) // Verify we're getting back the outputs we expect - assert.Equal(t, "starfish.uat.release.fire.water.earth.air", label3n["id"]) + // The tenant from normalized label1 should be "hrh" not "h.r.h." + assert.Equal(t, "starfish.hrh.uat.release.fire.water.earth.air", label3n["id"]) assert.Equal(t, label1Tags["Name"], label3nTags["Name"], "Tag from label1 normalized context should overwrite label3n generated tag") assert.Equal(t, expectedLabel3nContext, label3nContext) @@ -192,17 +206,19 @@ func TestExamplesComplete(t *testing.T) { // Verify that apply with `label_key_case=title` and `label_value_case=lower` returns expected values of id, tags, context tags label8dExpectedTags := map[string]string{ - "Attributes": "cluster", - "Environment": "demo", - "Name": "eg-demo-blue-cluster", - "Namespace": "eg", + "Attributes": "cluster", + "Environment": "demo", + "Name": "eg-demo-blue-cluster", + // Suppressed by labels_as_tags: "Namespace": "eg", "kubernetes.io/cluster/": "shared", } label8dID := terraform.Output(t, terraformOptions, "label8d_id") label8dContextID := terraform.Output(t, terraformOptions, "label8d_context_id") + label8dChained := terraform.Output(t, terraformOptions, "label8d_chained_context_labels_as_tags") assert.Equal(t, "eg-demo-blue-cluster", label8dID) assert.Equal(t, label8dID, label8dContextID, "ID and context ID should be equal") + assert.Equal(t, "attributes-environment-name-stage", label8dChained) label8dTags := terraform.OutputMap(t, terraformOptions, "label8d_tags") label8dContextTags := terraform.OutputMap(t, terraformOptions, "label8d_context_tags") diff --git a/variables.tf b/variables.tf index 40fb268..4b182ce 100644 --- a/variables.tf +++ b/variables.tf @@ -3,6 +3,7 @@ variable "context" { default = { enabled = true namespace = null + tenant = null environment = null stage = null name = null @@ -15,6 +16,15 @@ variable "context" { 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. @@ -44,32 +54,42 @@ variable "enabled" { variable "namespace" { type = string default = null - description = "Namespace, which could be your organization name or abbreviation, e.g. 'eg' or 'cp'" + 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 = "Environment, e.g. 'uw2', 'us-west-2', OR 'prod', 'staging', 'dev', 'UAT'" + 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 = "Stage, e.g. 'prod', 'staging', 'dev', OR 'source', 'build', 'test', 'deploy', 'release'" + description = "ID element. Usually used to indicate role, e.g. 'prod', 'staging', 'source', 'build', 'test', 'deploy', 'release'" } variable "name" { type = string default = null - description = "Solution name, e.g. 'app' or 'jenkins'" + 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 `namespace`, `environment`, `stage`, `name` and `attributes`. + Delimiter to be used between ID elements. Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. EOT } @@ -77,36 +97,64 @@ variable "delimiter" { variable "attributes" { type = list(string) default = [] - description = "Additional attributes (e.g. `1`)" + 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 = "Additional tags (e.g. `map('BusinessUnit','XYZ')`" + 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 = "Additional tags for appending to tags_as_list_of_maps. Not added to `tags`." + 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 naming order of the id output and Name tag. + 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 5 elements, but at least one must be present. - EOT + 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 - Regex to replace chars with empty string in `namespace`, `environment`, `stage` and `name`. + 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 } @@ -117,7 +165,7 @@ variable "id_length_limit" { description = <<-EOT Limit `id` to this many characters (minimum 6). Set to `0` for unlimited length. - Set to `null` for default, which is `0`. + Set to `null` for keep the existing setting, which defaults to `0`. Does not affect `id_full`. EOT validation { @@ -130,7 +178,8 @@ variable "label_key_case" { type = string default = null description = <<-EOT - The letter case of label keys (`tag` names) (i.e. `name`, `namespace`, `environment`, `stage`, `attributes`) to use in `tags`. + 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 @@ -145,8 +194,11 @@ variable "label_value_case" { type = string default = null description = <<-EOT - The letter case of output label values (also used in `tags` and `id`). + 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 @@ -155,3 +207,22 @@ variable "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 +}