From 1fbb2cb330f64c2955f9e03337831a0f9928eab6 Mon Sep 17 00:00:00 2001 From: Liam Nesteroff <35284740+lnesteroff@users.noreply.github.com> Date: Mon, 23 Jun 2025 16:24:43 +1000 Subject: [PATCH] Added tag factory option for organization module (#3178) * Added tag factory option for organization module * added tags-factory tests * added tag factory for project module * missing header * added lookup catch for org tag values and fixed incorrect link in doco * fixed factory locals from copy/paste * added full doco/tests for project tags factory. fixed failed test looking for ID in yamls * added context option for factories_config to configure existing K/Vs --------- Co-authored-by: Ludovico Magnocavallo --- modules/organization/README.md | 50 +++++++++-- modules/organization/tags.tf | 86 +++++++++++++++---- modules/organization/variables.tf | 3 + modules/project/README.md | 75 ++++++++++++---- modules/project/tags.tf | 82 ++++++++++++++---- modules/project/variables.tf | 3 + .../organization/examples/tags-factory.yaml | 42 +++++++++ .../project/examples/tags-factory.yaml | 53 ++++++++++++ 8 files changed, 335 insertions(+), 59 deletions(-) create mode 100644 tests/modules/organization/examples/tags-factory.yaml create mode 100644 tests/modules/project/examples/tags-factory.yaml diff --git a/modules/organization/README.md b/modules/organization/README.md index 525ae912a..4ed1b120a 100644 --- a/modules/organization/README.md +++ b/modules/organization/README.md @@ -26,6 +26,7 @@ To manage organization policies, the `orgpolicy.googleapis.com` service should b - [Custom Roles](#custom-roles) - [Custom Roles Factory](#custom-roles-factory) - [Tags](#tags) + - [Tags Factory](#tags-factory) - [Files](#files) - [Variables](#variables) - [Outputs](#outputs) @@ -134,7 +135,7 @@ Refer to the [project module](../project/README.md#iam) for examples of the IAM ### Organization Policy Factory -See the [organization policy factory in the project module](../project#organization-policy-factory). +See the [organization policy factory in the project module](../project/README.md#organization-policy-factory). ### Organization Policy Custom Constraints @@ -512,6 +513,41 @@ module "org" { # tftest modules=1 resources=5 inventory=network-tags.yaml e2e serial ``` +### Tags Factory + +Tags can also be specified via a factory in a similar way to organization policies and policy constraints. Each file is mapped to tag key, where + +- the key name defaults to the file name but can be overridden via a `name` attribute in the yaml +- The structure of the YAML file allows defining the `description`, `iam` bindings, and a map of `values` for the tag key, including their own descriptions and IAM. +- Tags defined via the `tags` and `network_tags` variables are merged with those from the factory, and will override factory definitions in case of duplicate names. + +The example below deploys a `cost-center` tag key and its values from a YAML file. + +```hcl +module "org" { + source = "./fabric/modules/organization" + organization_id = var.organization_id + factories_config = { + tags = "data/tags" + } +} +# tftest modules=1 resources=4 files=cost-center inventory=tags-factory.yaml +``` + +```yaml +# tftest-file id=cost-center path=data/tags/cost-center.yaml + +description: "Tag for internal cost allocation." +iam: + "roles/resourcemanager.tagViewer": + - "group:finance-team@example.com" +values: + engineering: + description: "Engineering department." + marketing: + description: "Marketing department." +``` + ## Files @@ -524,7 +560,7 @@ module "org" { | [org-policy-custom-constraints.tf](./org-policy-custom-constraints.tf) | None | google_org_policy_custom_constraint | | [organization-policies.tf](./organization-policies.tf) | Organization-level organization policies. | google_org_policy_policy | | [outputs.tf](./outputs.tf) | Module outputs. | | -| [tags.tf](./tags.tf) | None | google_tags_tag_binding · google_tags_tag_key · google_tags_tag_key_iam_binding · google_tags_tag_key_iam_member · google_tags_tag_value · google_tags_tag_value_iam_binding · google_tags_tag_value_iam_member | +| [tags.tf](./tags.tf) | Manages GCP Secure Tags, keys, values, and IAM. | google_tags_tag_binding · google_tags_tag_key · google_tags_tag_key_iam_binding · google_tags_tag_key_iam_member · google_tags_tag_value · google_tags_tag_value_iam_binding · google_tags_tag_value_iam_member | | [variables-iam.tf](./variables-iam.tf) | None | | | [variables-logging.tf](./variables-logging.tf) | None | | | [variables-tags.tf](./variables-tags.tf) | None | | @@ -535,11 +571,11 @@ module "org" { | name | description | type | required | default | |---|---|:---:|:---:|:---:| -| [organization_id](variables.tf#L96) | Organization id in organizations/nnnnnn format. | string | ✓ | | +| [organization_id](variables.tf#L99) | Organization id in organizations/nnnnnn format. | string | ✓ | | | [contacts](variables.tf#L17) | List of essential contacts for this resource. Must be in the form EMAIL -> [NOTIFICATION_TYPES]. Valid notification types are ALL, SUSPENSION, SECURITY, TECHNICAL, BILLING, LEGAL, PRODUCT_UPDATES. | map(list(string)) | | {} | | [custom_roles](variables.tf#L24) | Map of role name => list of permissions to create in this project. | map(list(string)) | | {} | -| [factories_config](variables.tf#L31) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} | -| [firewall_policy](variables.tf#L45) | Hierarchical firewall policies to associate to the organization. | object({…}) | | null | +| [factories_config](variables.tf#L31) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} | +| [firewall_policy](variables.tf#L48) | Hierarchical firewall policies to associate to the organization. | object({…}) | | null | | [iam](variables-iam.tf#L17) | IAM bindings, in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} | | [iam_bindings](variables-iam.tf#L24) | Authoritative IAM bindings in {KEY => {role = ROLE, members = [], condition = {}}}. Keys are arbitrary. | map(object({…})) | | {} | | [iam_bindings_additive](variables-iam.tf#L39) | Individual additive IAM bindings. Keys are arbitrary. | map(object({…})) | | {} | @@ -550,8 +586,8 @@ module "org" { | [logging_settings](variables-logging.tf#L35) | Default settings for logging resources. | object({…}) | | null | | [logging_sinks](variables-logging.tf#L45) | Logging sinks to create for the organization. | map(object({…})) | | {} | | [network_tags](variables-tags.tf#L17) | Network tags by key name. If `id` is provided, key creation is skipped. The `iam` attribute behaves like the similarly named one at module level. | map(object({…})) | | {} | -| [org_policies](variables.tf#L54) | Organization policies applied to this organization keyed by policy name. | map(object({…})) | | {} | -| [org_policy_custom_constraints](variables.tf#L82) | Organization policy custom constraints keyed by constraint name. | map(object({…})) | | {} | +| [org_policies](variables.tf#L57) | Organization policies applied to this organization keyed by policy name. | map(object({…})) | | {} | +| [org_policy_custom_constraints](variables.tf#L85) | Organization policy custom constraints keyed by constraint name. | map(object({…})) | | {} | | [tag_bindings](variables-tags.tf#L81) | Tag bindings for this organization, in key => tag value id format. | map(string) | | {} | | [tags](variables-tags.tf#L88) | Tags by key name. If `id` is provided, key or value creation is skipped. The `iam` attribute behaves like the similarly named one at module level. | map(object({…})) | | {} | diff --git a/modules/organization/tags.tf b/modules/organization/tags.tf index 0321818a5..6e024f19b 100644 --- a/modules/organization/tags.tf +++ b/modules/organization/tags.tf @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -14,14 +14,65 @@ * limitations under the License. */ +# tfdoc:file:description Manages GCP Secure Tags, keys, values, and IAM. + locals { + _factory_tags_data_path = pathexpand(coalesce(var.factories_config.tags, "-")) + _factory_tags_data_raw = { + for f in try(fileset(local._factory_tags_data_path, "*.yaml"), []) : + f => yamldecode(file("${local._factory_tags_data_path}/${f}")) + } + _factory_tags_data = { + for f, v_raw in local._factory_tags_data_raw : + coalesce(lookup(v_raw, "name", null), trimsuffix(f, ".yaml")) => { + description = lookup(v_raw, "description", null) + iam = lookup(v_raw, "iam", {}) + iam_bindings = lookup(v_raw, "iam_bindings", {}) + iam_bindings_additive = lookup(v_raw, "iam_bindings_additive", {}) + network = lookup(v_raw, "network", null) + values = { + for vk, vv_raw in lookup(v_raw, "values", {}) : vk => { + description = lookup(vv_raw, "description", null) + iam = lookup(vv_raw, "iam", {}) + iam_bindings = lookup(vv_raw, "iam_bindings", {}) + iam_bindings_additive = lookup(vv_raw, "iam_bindings_additive", {}) + } + } + } + } + _tags_merged = merge(local._factory_tags_data, var.tags, var.network_tags) + tags = { + for k, v in local._tags_merged : k => { + description = v.description + iam = v.iam + iam_bindings = v.iam_bindings + iam_bindings_additive = v.iam_bindings_additive + network = lookup(v, "network", null) + id = try(coalesce( + lookup(v, "id", null), + lookup(var.factories_config.context.tag_keys, k, null) + ), null) + values = { + for vk, vv in lookup(v, "values", {}) : vk => { + description = vv.description + iam = vv.iam + iam_bindings = vv.iam_bindings + iam_bindings_additive = vv.iam_bindings_additive + id = try(coalesce( + lookup(vv, "id", null), + lookup(var.factories_config.context.tag_values, "${k}/${vk}", null) + ), null) + } + } + } + } _tag_iam = flatten([ for k, v in local.tags : [ - for role in keys(v.iam) : { - # we cycle on keys here so we don't risk injecting dynamic values + for role in keys(lookup(v, "iam", {})) : { + # We cycle on keys here so we don't risk injecting dynamic values. role = role tag = k - tag_id = v.id + tag_id = lookup(v, "id", null) } ] ]) @@ -38,18 +89,18 @@ locals { ]) _tag_values = flatten([ for k, v in local.tags : [ - for vk, vv in v.values : { + for vk, vv in lookup(v, "values", {}) : { description = vv.description, key = "${k}/${vk}" - iam_bindings = keys(vv.iam_bindings) - iam_bindings_additive = keys(vv.iam_bindings_additive) - id = try(vv.id, null) + iam_bindings = keys(lookup(vv, "iam_bindings", {})) + iam_bindings_additive = keys(lookup(vv, "iam_bindings_additive", {})) + id = lookup(vv, "id", null) name = vk # we only store keys here so we don't risk injecting dynamic values - roles = keys(vv.iam) + roles = keys(lookup(vv, "iam", {})) tag = k - tag_id = v.id - tag_network = try(v.network, null) != null + tag_id = lookup(v, "id", null) + tag_network = lookup(v, "network", null) != null } ] ]) @@ -58,19 +109,19 @@ locals { } tag_iam_bindings = merge([ for k, v in local.tags : { - for bk in keys(v.iam_bindings) : "${k}:${bk}" => { + for bk in keys(lookup(v, "iam_bindings", {})) : "${k}:${bk}" => { binding = bk tag = k - tag_id = v.id + tag_id = lookup(v, "id", null) } } ]...) tag_iam_bindings_additive = merge([ for k, v in local.tags : { - for bk in keys(v.iam_bindings_additive) : "${k}:${bk}" => { + for bk in keys(lookup(v, "iam_bindings_additive", {})) : "${k}:${bk}" => { binding = bk tag = k - tag_id = v.id + tag_id = lookup(v, "id", null) } } ]...) @@ -104,13 +155,12 @@ locals { tag_values = { for v in local._tag_values : v.key => v } - tags = merge(var.tags, var.network_tags) } # keys resource "google_tags_tag_key" "default" { - for_each = { for k, v in local.tags : k => v if v.id == null } + for_each = { for k, v in local.tags : k => v if lookup(v, "id", null) == null } parent = var.organization_id purpose = ( lookup(each.value, "network", null) == null ? null : "GCE_FIREWALL" @@ -167,7 +217,7 @@ resource "google_tags_tag_key_iam_member" "bindings" { # values resource "google_tags_tag_value" "default" { - for_each = { for k, v in local.tag_values : k => v if v.id == null } + for_each = { for k, v in local.tag_values : k => v if lookup(v, "id", null) == null } parent = ( each.value.tag_id == null ? google_tags_tag_key.default[each.value.tag].id diff --git a/modules/organization/variables.tf b/modules/organization/variables.tf index e9b67bb01..89440f3b3 100644 --- a/modules/organization/variables.tf +++ b/modules/organization/variables.tf @@ -34,8 +34,11 @@ variable "factories_config" { custom_roles = optional(string) org_policies = optional(string) org_policy_custom_constraints = optional(string) + tags = optional(string) context = optional(object({ org_policies = optional(map(map(string)), {}) + tag_keys = optional(map(string), {}) + tag_values = optional(map(string), {}) }), {}) }) nullable = false diff --git a/modules/project/README.md b/modules/project/README.md index 9dfcddb7a..e734e5bb3 100644 --- a/modules/project/README.md +++ b/modules/project/README.md @@ -21,6 +21,7 @@ This module implements the creation and management of one GCP project including - [Log Scopes](#log-scopes) - [Cloud KMS Encryption Keys](#cloud-kms-encryption-keys) - [Tags](#tags) + - [Tags Factory](#tags-factory) - [Tag Bindings](#tag-bindings) - [Project-scoped Tags](#project-scoped-tags) - [Custom Roles](#custom-roles) @@ -995,6 +996,44 @@ module "project" { # tftest modules=1 resources=8 inventory=tags-network.yaml ``` +### Tags Factory + +Tags can also be specified via a factory in a similar way to organization policies and policy constraints. Each file is mapped to tag key, where + +- the key name defaults to the file name but can be overridden via a `name` attribute in the yaml +- The structure of the YAML file allows defining the `description`, `iam` bindings, and a map of `values` for the tag key, including their own descriptions and IAM. +- Tags defined via the `tags` and `network_tags` variables are merged with those from the factory, and will override factory definitions in case of duplicate names. + +The example below deploys a `workloads` tag key and its values from a YAML file. + +```hcl +module "project" { + source = "./fabric/modules/project" + billing_account = var.billing_account_id + name = "project" + prefix = var.prefix + parent = var.folder_id + factories_config = { + tags = "data/tags" + } +} +# tftest modules=1 resources=5 files=workload-tags inventory=tags-factory.yaml +``` + +```yaml +# tftest-file id=workload-tags path=data/tags/workloads.yaml + +description: "Tag for workload classifications." +iam: + "roles/resourcemanager.tagViewer": + - "group:devops@example.com" +values: + frontend: + description: "Frontend workloads." + backend: + description: "Backend workloads." +``` + ## Tag Bindings You can bind secure tags to a project with the `tag_bindings` attribute @@ -1678,7 +1717,7 @@ alerts: | [quotas.tf](./quotas.tf) | None | google_cloud_quotas_quota_preference | | [service-agents.tf](./service-agents.tf) | Service agents supporting resources. | google_project_default_service_accounts · google_project_iam_member · google_project_service_identity | | [shared-vpc.tf](./shared-vpc.tf) | Shared VPC project-level configuration. | google_compute_shared_vpc_host_project · google_compute_shared_vpc_service_project · google_compute_subnetwork_iam_member · google_project_iam_member | -| [tags.tf](./tags.tf) | None | google_tags_tag_binding · google_tags_tag_key · google_tags_tag_key_iam_binding · google_tags_tag_key_iam_member · google_tags_tag_value · google_tags_tag_value_iam_binding · google_tags_tag_value_iam_member | +| [tags.tf](./tags.tf) | Manages GCP Secure Tags, keys, values, and IAM. | google_tags_tag_binding · google_tags_tag_key · google_tags_tag_key_iam_binding · google_tags_tag_key_iam_member · google_tags_tag_value · google_tags_tag_value_iam_binding · google_tags_tag_value_iam_member | | [variables-iam.tf](./variables-iam.tf) | None | | | [variables-observability.tf](./variables-observability.tf) | None | | | [variables-quotas.tf](./variables-quotas.tf) | None | | @@ -1691,7 +1730,7 @@ alerts: | name | description | type | required | default | |---|---|:---:|:---:|:---:| -| [name](variables.tf#L115) | Project name and id suffix. | string | ✓ | | +| [name](variables.tf#L118) | Project name and id suffix. | string | ✓ | | | [alerts](variables-observability.tf#L17) | Monitoring alerts. | map(object({…})) | | {} | | [auto_create_network](variables.tf#L17) | Whether to create the default network for the project. | bool | | false | | [billing_account](variables.tf#L23) | Billing account id. | string | | null | @@ -1702,14 +1741,14 @@ alerts: | [default_service_account](variables.tf#L56) | Project default service account setting: can be one of `delete`, `deprivilege`, `disable`, or `keep`. | string | | "keep" | | [deletion_policy](variables.tf#L69) | Deletion policy setting for this project. | string | | "DELETE" | | [descriptive_name](variables.tf#L80) | Name of the project name. Used for project name instead of `name` variable. | string | | null | -| [factories_config](variables.tf#L86) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} | +| [factories_config](variables.tf#L86) | Paths to data files and folders that enable factory functionality. | object({…}) | | {} | | [iam](variables-iam.tf#L17) | Authoritative IAM bindings in {ROLE => [MEMBERS]} format. | map(list(string)) | | {} | | [iam_bindings](variables-iam.tf#L24) | Authoritative IAM bindings in {KEY => {role = ROLE, members = [], condition = {}}}. Keys are arbitrary. | map(object({…})) | | {} | | [iam_bindings_additive](variables-iam.tf#L39) | Individual additive IAM bindings. Keys are arbitrary. | map(object({…})) | | {} | | [iam_by_principals](variables-iam.tf#L61) | Authoritative IAM binding in {PRINCIPAL => [ROLES]} format. Principals need to be statically defined to avoid errors. Merged internally with the `iam` variable. | map(list(string)) | | {} | | [iam_by_principals_additive](variables-iam.tf#L54) | Additive IAM binding in {PRINCIPAL => [ROLES]} format. Principals need to be statically defined to avoid errors. Merged internally with the `iam_bindings_additive` variable. | map(list(string)) | | {} | -| [labels](variables.tf#L102) | Resource labels. | map(string) | | {} | -| [lien_reason](variables.tf#L109) | If non-empty, creates a project lien with this description. | string | | null | +| [labels](variables.tf#L105) | Resource labels. | map(string) | | {} | +| [lien_reason](variables.tf#L112) | If non-empty, creates a project lien with this description. | string | | null | | [log_scopes](variables-observability.tf#L117) | Log scopes under this project. | map(object({…})) | | {} | | [logging_data_access](variables-observability.tf#L127) | Control activation of data access logs. The special 'allServices' key denotes configuration for all services. | map(object({…})) | | {} | | [logging_exclusions](variables-observability.tf#L138) | Logging exclusions for this project in the form {NAME -> FILTER}. | map(string) | | {} | @@ -1718,22 +1757,22 @@ alerts: | [metric_scopes](variables-observability.tf#L216) | List of projects that will act as metric scopes for this project. | list(string) | | [] | | [network_tags](variables-tags.tf#L17) | Network tags by key name. If `id` is provided, key creation is skipped. The `iam` attribute behaves like the similarly named one at module level. | map(object({…})) | | {} | | [notification_channels](variables-observability.tf#L223) | Monitoring notification channels. | map(object({…})) | | {} | -| [org_policies](variables.tf#L120) | Organization policies applied to this project keyed by policy name. | map(object({…})) | | {} | -| [parent](variables.tf#L148) | Parent folder or organization in 'folders/folder_id' or 'organizations/org_id' format. | string | | null | -| [prefix](variables.tf#L158) | Optional prefix used to generate project id and name. | string | | null | -| [project_reuse](variables.tf#L168) | Reuse existing project if not null. If name and number are not passed in, a data source is used. | object({…}) | | null | +| [org_policies](variables.tf#L123) | Organization policies applied to this project keyed by policy name. | map(object({…})) | | {} | +| [parent](variables.tf#L151) | Parent folder or organization in 'folders/folder_id' or 'organizations/org_id' format. | string | | null | +| [prefix](variables.tf#L161) | Optional prefix used to generate project id and name. | string | | null | +| [project_reuse](variables.tf#L171) | Reuse existing project if not null. If name and number are not passed in, a data source is used. | object({…}) | | null | | [quotas](variables-quotas.tf#L17) | Service quota configuration. | map(object({…})) | | {} | -| [service_agents_config](variables.tf#L188) | Automatic service agent configuration options. | object({…}) | | {} | -| [service_config](variables.tf#L198) | Configure service API activation. | object({…}) | | {…} | -| [service_encryption_key_ids](variables.tf#L210) | Service Agents to be granted encryption/decryption permissions over Cloud KMS encryption keys. Format {SERVICE_AGENT => [KEY_ID]}. | map(list(string)) | | {} | -| [services](variables.tf#L217) | Service APIs to enable. | list(string) | | [] | -| [shared_vpc_host_config](variables.tf#L223) | Configures this project as a Shared VPC host project (mutually exclusive with shared_vpc_service_project). | object({…}) | | null | -| [shared_vpc_service_config](variables.tf#L233) | Configures this project as a Shared VPC service project (mutually exclusive with shared_vpc_host_config). | object({…}) | | {…} | -| [skip_delete](variables.tf#L270) | Deprecated. Use deletion_policy. | bool | | null | +| [service_agents_config](variables.tf#L191) | Automatic service agent configuration options. | object({…}) | | {} | +| [service_config](variables.tf#L201) | Configure service API activation. | object({…}) | | {…} | +| [service_encryption_key_ids](variables.tf#L213) | Service Agents to be granted encryption/decryption permissions over Cloud KMS encryption keys. Format {SERVICE_AGENT => [KEY_ID]}. | map(list(string)) | | {} | +| [services](variables.tf#L220) | Service APIs to enable. | list(string) | | [] | +| [shared_vpc_host_config](variables.tf#L226) | Configures this project as a Shared VPC host project (mutually exclusive with shared_vpc_service_project). | object({…}) | | null | +| [shared_vpc_service_config](variables.tf#L236) | Configures this project as a Shared VPC service project (mutually exclusive with shared_vpc_host_config). | object({…}) | | {…} | +| [skip_delete](variables.tf#L273) | Deprecated. Use deletion_policy. | bool | | null | | [tag_bindings](variables-tags.tf#L81) | Tag bindings for this project, in key => tag value id format. | map(string) | | null | | [tags](variables-tags.tf#L88) | Tags by key name. If `id` is provided, key or value creation is skipped. The `iam` attribute behaves like the similarly named one at module level. | map(object({…})) | | {} | -| [universe](variables.tf#L282) | GCP universe where to deploy the project. The prefix will be prepended to the project id. | object({…}) | | null | -| [vpc_sc](variables.tf#L291) | VPC-SC configuration for the project, use when `ignore_changes` for resources is set in the VPC-SC module. | object({…}) | | null | +| [universe](variables.tf#L285) | GCP universe where to deploy the project. The prefix will be prepended to the project id. | object({…}) | | null | +| [vpc_sc](variables.tf#L294) | VPC-SC configuration for the project, use when `ignore_changes` for resources is set in the VPC-SC module. | object({…}) | | null | ## Outputs diff --git a/modules/project/tags.tf b/modules/project/tags.tf index 1f9ff378b..d70a0f9aa 100644 --- a/modules/project/tags.tf +++ b/modules/project/tags.tf @@ -14,14 +14,65 @@ * limitations under the License. */ +# tfdoc:file:description Manages GCP Secure Tags, keys, values, and IAM. + locals { + _factory_tags_data_path = pathexpand(coalesce(var.factories_config.tags, "-")) + _factory_tags_data_raw = { + for f in try(fileset(local._factory_tags_data_path, "*.yaml"), []) : + f => yamldecode(file("${local._factory_tags_data_path}/${f}")) + } + _factory_tags_data = { + for f, v_raw in local._factory_tags_data_raw : + coalesce(lookup(v_raw, "name", null), trimsuffix(f, ".yaml")) => { + description = lookup(v_raw, "description", null) + iam = lookup(v_raw, "iam", {}) + iam_bindings = lookup(v_raw, "iam_bindings", {}) + iam_bindings_additive = lookup(v_raw, "iam_bindings_additive", {}) + network = lookup(v_raw, "network", null) + values = { + for vk, vv_raw in lookup(v_raw, "values", {}) : vk => { + description = lookup(vv_raw, "description", null) + iam = lookup(vv_raw, "iam", {}) + iam_bindings = lookup(vv_raw, "iam_bindings", {}) + iam_bindings_additive = lookup(vv_raw, "iam_bindings_additive", {}) + } + } + } + } + _tags_merged = merge(local._factory_tags_data, var.tags, var.network_tags) + tags = { + for k, v in local._tags_merged : k => { + description = v.description + iam = v.iam + iam_bindings = v.iam_bindings + iam_bindings_additive = v.iam_bindings_additive + network = lookup(v, "network", null) + id = try(coalesce( + lookup(v, "id", null), + lookup(var.factories_config.context.tag_keys, k, null) + ), null) + values = { + for vk, vv in lookup(v, "values", {}) : vk => { + description = vv.description + iam = vv.iam + iam_bindings = vv.iam_bindings + iam_bindings_additive = vv.iam_bindings_additive + id = try(coalesce( + lookup(vv, "id", null), + lookup(var.factories_config.context.tag_values, "${k}/${vk}", null) + ), null) + } + } + } + } _tag_iam = flatten([ for k, v in local.tags : [ - for role in keys(v.iam) : { + for role in keys(lookup(v, "iam", {})) : { # we cycle on keys here so we don't risk injecting dynamic values role = role tag = k - tag_id = v.id + tag_id = lookup(v, "id", null) } ] ]) @@ -38,18 +89,18 @@ locals { ]) _tag_values = flatten([ for k, v in local.tags : [ - for vk, vv in v.values : { + for vk, vv in lookup(v, "values", {}) : { description = vv.description, key = "${k}/${vk}" - iam_bindings = keys(vv.iam_bindings) - iam_bindings_additive = keys(vv.iam_bindings_additive) - id = try(vv.id, null) + iam_bindings = keys(lookup(vv, "iam_bindings", {})) + iam_bindings_additive = keys(lookup(vv, "iam_bindings_additive", {})) + id = lookup(vv, "id", null) name = vk # we only store keys here so we don't risk injecting dynamic values - roles = keys(vv.iam) + roles = keys(lookup(vv, "iam", {})) tag = k - tag_id = v.id - tag_network = try(v.network, null) != null + tag_id = lookup(v, "id", null) + tag_network = lookup(v, "network", null) != null } ] ]) @@ -58,19 +109,19 @@ locals { } tag_iam_bindings = merge([ for k, v in local.tags : { - for bk in keys(v.iam_bindings) : "${k}:${bk}" => { + for bk in keys(lookup(v, "iam_bindings", {})) : "${k}:${bk}" => { binding = bk tag = k - tag_id = v.id + tag_id = lookup(v, "id", null) } } ]...) tag_iam_bindings_additive = merge([ for k, v in local.tags : { - for bk in keys(v.iam_bindings_additive) : "${k}:${bk}" => { + for bk in keys(lookup(v, "iam_bindings_additive", {})) : "${k}:${bk}" => { binding = bk tag = k - tag_id = v.id + tag_id = lookup(v, "id", null) } } ]...) @@ -104,13 +155,12 @@ locals { tag_values = { for v in local._tag_values : v.key => v } - tags = merge(var.tags, var.network_tags) } # keys resource "google_tags_tag_key" "default" { - for_each = { for k, v in local.tags : k => v if v.id == null } + for_each = { for k, v in local.tags : k => v if lookup(v, "id", null) == null } parent = "projects/${local.project.project_id}" purpose = ( lookup(each.value, "network", null) == null ? null : "GCE_FIREWALL" @@ -167,7 +217,7 @@ resource "google_tags_tag_key_iam_member" "bindings" { # values resource "google_tags_tag_value" "default" { - for_each = { for k, v in local.tag_values : k => v if v.id == null } + for_each = { for k, v in local.tag_values : k => v if lookup(v, "id", null) == null } parent = ( each.value.tag_id == null ? google_tags_tag_key.default[each.value.tag].id diff --git a/modules/project/variables.tf b/modules/project/variables.tf index 3fa80e9fb..99b9d9a74 100644 --- a/modules/project/variables.tf +++ b/modules/project/variables.tf @@ -90,9 +90,12 @@ variable "factories_config" { observability = optional(string) org_policies = optional(string) quotas = optional(string) + tags = optional(string) context = optional(object({ notification_channels = optional(map(string), {}) org_policies = optional(map(map(string)), {}) + tag_keys = optional(map(string), {}) + tag_values = optional(map(string), {}) }), {}) }) nullable = false diff --git a/tests/modules/organization/examples/tags-factory.yaml b/tests/modules/organization/examples/tags-factory.yaml new file mode 100644 index 000000000..e3f27a66c --- /dev/null +++ b/tests/modules/organization/examples/tags-factory.yaml @@ -0,0 +1,42 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +values: + module.org.google_tags_tag_key.default["cost-center"]: + description: Tag for internal cost allocation. + parent: organizations/1122334455 + purpose: null + purpose_data: null + short_name: cost-center + timeouts: null + module.org.google_tags_tag_key_iam_binding.default["cost-center:roles/resourcemanager.tagViewer"]: + condition: [] + members: + - group:finance-team@example.com + role: roles/resourcemanager.tagViewer + module.org.google_tags_tag_value.default["cost-center/engineering"]: + description: Engineering department. + short_name: engineering + timeouts: null + module.org.google_tags_tag_value.default["cost-center/marketing"]: + description: "Marketing department." + short_name: marketing + timeouts: null + +counts: + google_tags_tag_key: 1 + google_tags_tag_key_iam_binding: 1 + google_tags_tag_value: 2 + modules: 1 + resources: 4 diff --git a/tests/modules/project/examples/tags-factory.yaml b/tests/modules/project/examples/tags-factory.yaml new file mode 100644 index 000000000..1d2ad4c96 --- /dev/null +++ b/tests/modules/project/examples/tags-factory.yaml @@ -0,0 +1,53 @@ +# Copyright 2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +values: + module.project.google_project.project[0]: + auto_create_network: false + billing_account: 123456-123456-123456 + deletion_policy: 'DELETE' + folder_id: '1122334455' + labels: null + name: test-project + org_id: null + project_id: test-project + timeouts: null + module.project.google_tags_tag_key.default["workloads"]: + description: Tag for workload classifications. + parent: projects/test-project + purpose: null + purpose_data: null + short_name: workloads + timeouts: null + module.project.google_tags_tag_key_iam_binding.default["workloads:roles/resourcemanager.tagViewer"]: + condition: [] + members: + - group:devops@example.com + role: roles/resourcemanager.tagViewer + module.project.google_tags_tag_value.default["workloads/frontend"]: + description: "Frontend workloads." + short_name: frontend + timeouts: null + module.project.google_tags_tag_value.default["workloads/backend"]: + description: "Backend workloads." + short_name: backend + timeouts: null + +counts: + google_project: 1 + google_tags_tag_key: 1 + google_tags_tag_key_iam_binding: 1 + google_tags_tag_value: 2 + modules: 1 + resources: 5