Skip to content

primary_contact and secondary_contact labels can become invalid if email contains a period #1439

@maxweiss-delphra

Description

@maxweiss-delphra

Module: 4-projects/modules/single_project

The single_project module uses the local part of an email address for the primary_contact and secondary_contact labels. This is done using element(split("@", var.primary_contact), 0).

Code:
https://github.com/terraform-google-modules/terraform-example-foundation/blob/main/4-projects/modules/single_project/main.tf#L72-L73

  labels = {
    environment       = var.environment
    application_name  = var.application_name
    billing_code      = var.billing_code
    primary_contact   = element(split("@", var.primary_contact), 0)
    secondary_contact = element(split("@", var.secondary_contact), 0)
    business_code     = var.business_code
    env_code          = local.env_code
    vpc               = var.vpc
  }

The problem is that Google Cloud project labels have strict validation requirements and do not allow most special characters, including periods (.).

Google Cloud Label Requirements:

Labels must be key-value pairs. Keys and values can only contain lowercase letters, numeric characters, underscores, and dashes. All characters must use UTF-8 encoding, and entries can be no longer than 63 characters.

See: Google Cloud Documentation

If a contact email like [email protected] is provided, the module will attempt to create a label primary_contact = "jane.doe", which is invalid and will cause the Terraform apply to fail.

Expected Behavior

The module should sanitize the contact strings to make them valid Google Cloud labels. A common convention is to replace invalid characters like . with an allowed character or string, such as _ (underscore) or [dot].

Actual Behavior

The module passes the email local-part directly as the label value. If the local-part contains a period (a very common occurrence), the terraform apply fails due to an invalid label.

Steps to Reproduce

Use the single_project module from 4-projects.

Pass a variable for primary_contact that contains a period before the @ symbol (e.g., "[email protected]").

Run terraform plan or terraform apply.

The operation will fail validation.

Suggested Fix

The label values should be sanitized. For example, using the replace function:

Recommendation: Replace . with _ (underscore), as dashes are also common in email local-parts but might be interpreted as separators.

  labels = {
    environment       = var.environment
    application_name  = var.application_name
    billing_code      = var.billing_code
    primary_contact   = replace(element(split("@", var.primary_contact), 0), ".", "_")
    secondary_contact = replace(element(split("@", var.secondary_contact), 0), ".", "_")
    business_code     = var.business_code
    env_code          = local.env_code
    vpc               = var.vpc
  }

Alternatively, to match the user's suggestion:

    primary_contact   = replace(element(split("@", var.primary_contact), 0), ".", "[dot]")
    secondary_contact = replace(element(split("@", var.secondary_contact), 0), ".", "[dot]")

However, the underscore _ is more conventional for label sanitization. A more robust regex replacement for all invalid characters might be even better.

Expected behavior

No response

Observed behavior

No response

Terraform Configuration

/**
 * Copyright 2021 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.
 */

locals {
  env_code = element(split("", var.environment), 0)
  source_repos = setintersection(
    toset(keys(var.app_infra_pipeline_service_accounts)),
    toset(keys(var.sa_roles))
  )
  pipeline_roles = var.enable_cloudbuild_deploy ? flatten([
    for repo in local.source_repos : [
      for role in var.sa_roles[repo] :
      {
        repo = repo
        role = role
        sa   = var.app_infra_pipeline_service_accounts[repo]
      }
    ]
  ]) : []

  network_user_role = var.enable_cloudbuild_deploy ? flatten([
    for repo in local.source_repos : [
      for subnet in var.shared_vpc_subnets :
      {
        repo   = repo
        subnet = element(split("/", subnet), index(split("/", subnet), "subnetworks", ) + 1, )
        region = element(split("/", subnet), index(split("/", subnet), "regions") + 1, )
        sa     = var.app_infra_pipeline_service_accounts[repo]
      }
    ]
  ]) : []
}

module "project" {
  source  = "terraform-google-modules/project-factory/google"
  version = "~> 18.0"

  random_project_id        = true
  random_project_id_length = 4
  activate_apis            = distinct(concat(var.activate_apis, ["billingbudgets.googleapis.com"]))
  name                     = "${var.project_prefix}-${local.env_code}-${var.business_code}-${var.project_suffix}"
  org_id                   = var.org_id
  billing_account          = var.billing_account
  folder_id                = var.folder_id
  deletion_policy          = var.project_deletion_policy

  svpc_host_project_id = var.shared_vpc_host_project_id
  shared_vpc_subnets   = var.shared_vpc_subnets # Optional: To enable subnetting, replace to "module.networking_project.subnetwork_self_link"

  vpc_service_control_attach_enabled = var.vpc_service_control_attach_enabled
  vpc_service_control_attach_dry_run = var.vpc_service_control_attach_dry_run
  vpc_service_control_perimeter_name = var.vpc_service_control_perimeter_name
  vpc_service_control_sleep_duration = var.vpc_service_control_sleep_duration

  labels = {
    environment       = var.environment
    application_name  = var.application_name
    billing_code      = var.billing_code
    primary_contact   = element(split("@", var.primary_contact), 0)
    secondary_contact = element(split("@", var.secondary_contact), 0)
    business_code     = var.business_code
    env_code          = local.env_code
    vpc               = var.vpc
  }
  budget_alert_pubsub_topic   = var.project_budget.alert_pubsub_topic
  budget_alert_spent_percents = var.project_budget.alert_spent_percents
  budget_amount               = var.project_budget.budget_amount
  budget_alert_spend_basis    = var.project_budget.alert_spend_basis
}

# Additional roles to the App Infra Pipeline service account
resource "google_project_iam_member" "app_infra_pipeline_sa_roles" {
  for_each = { for pr in local.pipeline_roles : "${pr.repo}-${pr.sa}-${pr.role}" => pr }

  project = module.project.project_id
  role    = each.value.role
  member  = "serviceAccount:${each.value.sa}"
}

resource "google_folder_iam_member" "folder_network_viewer" {
  for_each = var.app_infra_pipeline_service_accounts

  folder = var.folder_id
  role   = "roles/compute.networkViewer"
  member = "serviceAccount:${each.value}"
}

resource "google_compute_subnetwork_iam_member" "service_account_role_to_vpc_subnets" {
  provider = google-beta
  for_each = { for nr in local.network_user_role : "${nr.repo}-${nr.subnet}-${nr.sa}" => nr }

  subnetwork = each.value.subnet
  role       = "roles/compute.networkUser"
  region     = each.value.region
  project    = var.shared_vpc_host_project_id
  member     = "serviceAccount:${each.value.sa}"
}

Terraform Version

1.13.4

Terraform Provider Versions

├── provider[terraform.io/builtin/terraform]
└── module.env
    ├── provider[registry.terraform.io/hashicorp/random] >= 3.3.0
    ├── provider[registry.terraform.io/hashicorp/google]
    ├── provider[terraform.io/builtin/terraform]
    └── module.product_pipeline
        ├── provider[registry.terraform.io/hashicorp/google-beta] >= 3.50.0, != 6.26.0, != 6.27.0, < 7.0.0
        ├── provider[registry.terraform.io/hashicorp/google] >= 3.50.0, != 6.26.0, != 6.27.0, < 7.0.0
        └── module.project
            ├── provider[registry.terraform.io/hashicorp/google] >= 5.41.0, < 8.0.0
            ├── provider[registry.terraform.io/hashicorp/google-beta] >= 5.41.0, < 8.0.0
            ├── module.shared_vpc_access
            │   ├── provider[registry.terraform.io/hashicorp/google] >= 3.43.0, < 8.0.0
            │   └── provider[registry.terraform.io/hashicorp/google-beta] >= 3.43.0, < 8.0.0
            ├── module.budget
            │   └── provider[registry.terraform.io/hashicorp/google] >= 4.28.0, < 8.0.0
            ├── module.essential_contacts
            │   ├── provider[registry.terraform.io/hashicorp/google] >= 3.43.0, < 8.0.0
            │   └── provider[registry.terraform.io/hashicorp/google-beta] >= 3.43.0, < 8.0.0
            ├── module.gsuite_group
            │   └── provider[registry.terraform.io/hashicorp/google] >= 3.43.0, < 8.0.0
            ├── module.project-factory
            │   ├── provider[registry.terraform.io/hashicorp/random] >= 2.2.0
            │   ├── provider[registry.terraform.io/hashicorp/time] >= 0.5.0
            │   ├── provider[registry.terraform.io/hashicorp/google] >= 5.41.0, < 8.0.0
            │   ├── provider[registry.terraform.io/hashicorp/google-beta] >= 5.41.0, < 8.0.0
            │   ├── provider[registry.terraform.io/hashicorp/null] >= 2.1.0
            │   └── module.project_services
            │       ├── provider[registry.terraform.io/hashicorp/google-beta] >= 3.43.0, < 8.0.0
            │       └── provider[registry.terraform.io/hashicorp/google] >= 3.43.0, < 8.0.0
            └── module.quotas
                └── provider[registry.terraform.io/hashicorp/google-beta] >= 4.11.0, < 8.0.0

Additional information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions