Terraform resource for_each with nested dynamic block keeps re-applying the same changes

So I have created a google_bigquery module to create datasets and set access.

The module iterates over a map of list of maps. It uses the each.key to create the datasets then iterates over the list of maps to create the dynamic access.

The module works as in:

  • It gives no errors nor warning
  • It deploys the resources
  • It populates the remote statefile appropriately.

The issue is that every time I ran terraform it wants to re-apply the same changes, over and over again.

Clearly something is not right but not sure what.

Terraform Version

Terraform v0.12.16 and v0.12.24 (tried both)
Terraform Google Provider 2.20.3 and 3.14.0 (tried both)
...

MAIN.TF

locals {
  env           = basename(path.cwd)
  project       = basename(abspath("${path.cwd}/../.."))
  project_name  = coalesce(var.project_name, format("%s-%s", local.project, local.env))
}

data "google_compute_zones" "available" {
  project = local.project_name
  region  = var.region
}

provider "google" {
  project = local.project_name
  region  = var.region
  version = "~> 2.0" #until 3.0 goes out of beta
}

terraform {
  required_version = ">= 0.12.16"
}

resource "google_bigquery_dataset" "main" {
  for_each                   = var.datasets
  dataset_id                 = upper("${each.key}_${local.env}")
  location                   = var.region
  delete_contents_on_destroy = true

  dynamic "access" {
    for_each = flatten([ for k, v in var.datasets : [
                 for i in each.value : {
                   role           = i.role
                   user_by_email  = i.user_by_email
                   group_by_email = i.group_by_email
                   dataset_id     = i.dataset_id
                   project_id     = i.project_id
                   table_id       = i.table_id
    }]])
    content {
      role           = lookup(access.value,"role", null)
      user_by_email  = lookup(access.value,"user_by_email",null)
      group_by_email = lookup(access.value,"group_by_email",null)
      view {
        dataset_id   = lookup(access.value,"dataset_id",null)
        project_id   = lookup(access.value,"project_id",null)
        table_id     = lookup(access.value,"table_id", null)
        }
    }
  }



  access {
    role          = "READER"
    special_group = "projectReaders"
  }

  access {
    role           = "OWNER"
    group_by_email = "Group"
  }

  access {
    role           = "OWNER"
    user_by_email  = "ServiceAccount"
  }

  access {
    role          = "WRITER"
    special_group = "projectWriters"
  }

}
...

VARIABLES.TF

variable "region" {
  description = ""
  default     = ""
}

variable "env" {
  default = ""
}

variable "project_name" {
  default = ""
}

variable "owner_group" {
  description = ""
  default     = ""
}

variable "owner_sa" {
  description = ""
  default = ""
}

variable "datasets" {
  description = "A map of objects, including dataset_isd abd access"
  type = map(list(map(string)))
}

TERRAFORM.TFVARS

datasets = {
  dataset01 = [
    {
      role           = "WRITER"
      user_by_email  = "email_address"
      group_by_email = ""
      dataset_id     = ""
      project_id     = ""
      table_id       = ""
    },
    {
      role           = ""
      user_by_email  = ""
      group_by_email = ""
      dataset_id     ="MY_OTHER_DATASET"
      project_id     ="my_other_project"
      table_id       ="my_test_view"
    }
  ]
  dataset02 = [
    {
      role           = "READER"
      user_by_email  = ""
      group_by_email = "group"
      dataset_id     = ""
      project_id     = ""
      table_id       = ""
    },
    {
      role           = ""
      user_by_email  = ""
      group_by_email = ""
      dataset_id     ="MY_OTHER_DATASET"
      project_id     ="my_other_project"
      table_id       ="my_test_view_2"
    }
  ]
}

Hi @paoloventriglia,

This sounds like a case where the underlying API is doing some normalization of the values you’re submitting that the provider is not dealing with, and so from the provider’s perspective it looks like the remote object does not match the configuration even though the two are functionally equivalent.

If that’s true then such a thing is normally considered to be a provider bug, but as a workaround you can usually fix it by reviewing the change that the provider is proposing to make in the plan output and updating your configuration to use whatever form of the data the remote API is using when normalizing.

Since you didn’t include the plan output I can’t make a specific suggestion, but common things to look for are situations where you wrote something in mixed case but the remote API is normalizing to lowercase, or where there are two different valid identifiers for an object and the remote API is normalizing to the other one.

Thanks @apparentlymart, that makes sense. I will look at the provider response again. I will post a plan as well later on.

I get the feeling it’s somehow related to me passing empty parameters, like this

{
      role           = ""
      user_by_email  = ""
      group_by_email = ""
      dataset_id     ="MY_OTHER_DATASET"
      project_id     ="my_other_project"
      table_id       ="my_test_view"
    }

But if I omitted them it complaints that they are required.

Hi @apparentlymart,

The issue seems to be that my dynamic block code can generate this output.

+ access {
        + role = "WRITER"
        + user_by_email ="email address"
  
        + view {}
    }

This plan is applied, no errors but yes the API response doesn’t have that empty view{}, so I guess tf wants to re-apply.

Any suggestions how I could make that view block conditional on the values not being null?

Thanks

Paolo

Indeed, it does seem like that’s the root cause of the problem here: the underlying Google API likely doesn’t distinguish between a view with everything set to null and not having a view object at all.

To work around that I suppose you’ll need to make the view block dynamic too, so you can conditionally omit it when needed:

  dynamic "access" {
    for_each = flatten([ for k, v in var.datasets : [
                 for i in each.value : {
                   role           = i.role
                   user_by_email  = i.user_by_email
                   group_by_email = i.group_by_email
                   dataset_id     = i.dataset_id
                   project_id     = i.project_id
                   table_id       = i.table_id
    }]])
    content {
      role           = lookup(access.value,"role", null)
      user_by_email  = lookup(access.value,"user_by_email",null)
      group_by_email = lookup(access.value,"group_by_email",null)
      dynamic "view" {
        for_each = access.value.dataset_id != null || access.value.project_id != null || access.value.table_id != null ? [access.value] : []
        content {
          dataset_id   = view.value["dataset_id"]
          project_id   = view.value["project_id"]
          table_id     = view.value["table_id"]
        }
      }
    }

thanks @apparentlymart that was really helpful and it sent me in the right direction. I also didn’t know you could do this, which is awesome.

for_each = access.value.dataset_id != null || access.value.project_id != null || access.value.table_id != null ? [access.value] : []

Unfortunately, it didn’t work as the view block got applied to all datasets.

I slightly refactored the module and now it works as I need it.

I have split the roles and the views into their own lists of maps within the parent map of datasets.

There are conditionals in each block so the dynamic block is only applied if the roles exist or views exist.

I also realized the dynamic block was iterating on the wrong iterator.

The dynamic block was iterating on var.datasets which was causing the permissions assigned to each dataset to be applied to all datasets. So now it has been changed to iterate on each.value (from the resource for_each).

resource "google_bigquery_dataset" "main" {
  for_each                   = var.datasets
  dataset_id                 = upper("${each.key}_${local.env}")
  location                   = var.region
  delete_contents_on_destroy = true

  dynamic "access" {
    for_each = flatten([for i in each.value : [
      for k, v in i : [
        for l in v :
        {
          role           = l.role
          user_by_email  = l.user_by_email
          group_by_email = l.group_by_email
          special_group  = l.special_group
      }]
      if k == "roles"
    ]])
    content {
      role           = access.value["role"]
      user_by_email  = access.value["user_by_email"]
      group_by_email = access.value["group_by_email"]
      special_group  = access.value["special_group"]
    }
  }

  dynamic "access" {
    for_each = flatten([for i in each.value : [
      for k, v in i : [
        for l in v :
        {
          dataset_id = l.dataset_id
          project_id = l.project_id
          table_id   = l.table_id
      }]
      if k == "views"
    ]])
    content {
      view {
        dataset_id = access.value["dataset_id"]
        project_id = access.value["project_id"]
        table_id   = access.value["table_id"]
      }
    }
  }
}
variable "datasets" {
  description = "A map of objects, including datasets IDs, roles and views"
  type        = map(list(map(list(map(string)))))
  default     = {}
}

continued....
datasets = {
  dataset01 = [
    {
      roles = [
        {
          role="WRITER"
          user_by_email="email_address"
          group_by_email=""
          special_group=""
        }
      ]
    views = [
        {
          dataset_id="MY_OTHER_DATASET"
          project_id="my_other_project"
          table_id="my_test_view"
        }
      ]
    }
  ]
  dataset02 = [
    {
      roles = [
        {
          role="READER"
          user_by_email=""
          group_by_email="group"
          special_group=""
        }
      ]
      views=[
        {
          dataset_id="MY_OTHER_DATASET"
          project_id="my_other_project"
          table_id="my_test_view_2"
        }
      ]
    }     
  ]
}