Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Terraform 0.12 map merging #744

Closed
justicel opened this issue Jun 13, 2019 · 16 comments
Closed

Terraform 0.12 map merging #744

justicel opened this issue Jun 13, 2019 · 16 comments
Labels

Comments

@justicel
Copy link

It appears that Terraform 0.12 no longer merges maps, but only selects the 'newest' one when using a chained set of tfvars files. It is documented at the bottom of this page:

https://www.terraform.io/docs/configuration/variables.html

This definitely breaks a good part of my workflow with 0.12. I'm wondering if perhaps this could be something that is supported in the terragrunt.hcl inputs = {} sections so that you can chain map variables and merge them like with 0.11.x?

@brikis98
Copy link
Member

Hm, I don't think I'd want to merge maps by default. I think that would be unexpected and is one of the reasons Terraform itself stopped doing it.

However, if there was an explicit way to opt into that sort of behavior, it would make sense. For example, in a few issues, the idea of adding a get_input helper has come up:

inputs = {
  foo = "bar"
  baz = get_input("../common.hcl", "baz")
}

That allows you to explicitly reuse a single input from another .hcl (or .tfvars?) file. That could be combined with an merge function:

inputs = {
  foo = "bar"
  baz = merge(get_input("../common.hcl", "baz"), { "foo": "bar" })
}

This is verbose, but clear and explicit... It would work if you only have a small number of such variables, but might not be effective if you have dozens.

@brikis98
Copy link
Member

As of v0.19.4, merge (and all other Terraform built-in functions) is now available.

@rdebrandt
Copy link

rdebrandt commented Sep 3, 2019

@brikis98

I've been trying to figure out the best way to do this, and I'm just not clear where terraform built-in functions can be run. The end goal in my scenario is specifically merge tags where I could have account level tags (or any arbitrary directory level) that would be applied to all sub-directories and merged into a single tag map to be applied to all resources.

The documentation under locals (https://github.com/gruntwork-io/terragrunt#locals) seems to indicate the only way to do this is by adding something like the below which works. The only issue is that now we have to use another configuration yaml configuration file throughout the directory structure, and the yaml files only need to be used for this type of map merge scenario which adds a bit of overhead/confusion.

locals {
  account_tags = yamldecode(file("${get_terragrunt_dir()}/${find_in_parent_folders("account.yaml")}"))
  local_tags = { 
     "app"  = "local_app"
     "type" = "local_type"
  }
  tags = merge(
    local.account_tags,
    local.local_tags
  )
}
inputs = {
  tags = local.tags
}

Is there a simpler/more explicit method to merge maps for this type of scenario that I am just missing?

Thanks

@yorinasub17
Copy link
Contributor

yorinasub17 commented Sep 3, 2019

It seems like you are looking for a better way to reference common variables. It would help to know what would be the ideal setup for you, since there are a few different ideas that we've floated around:

Implementing get_input helper function as described above

Implementing hcldecode helper function

This provides an alternative to yamldecode, where the vars are read as HCL so you can preserve the same syntax.

Updating dependency blocks to be able to load the config variables for access

In this setup, you can load the common vars from a base, reference terragrunt config in your setup. This is an alternative to get_input, where you would look something like:

dependency "common" {
  config_path = "../common"
  skip_outputs = true
}
inputs = {
  foo = "bar"
  baz = merge(dependency.common.inputs.baz, { "foo": "bar" })
}

@rdebrandt
Copy link

rdebrandt commented Sep 3, 2019

@yorinasub17 - Appreciate the quick response.

I think the cleanest solution would at least be able to have all the variable data within a single file type (so maybe just the hcl files), and being able to pull/merge from parent hcl files. Having the ability to pull in tfvars/yaml/json files does allow for a lot of flexibility, but makes it a bit more complicated to track variables through a directory structure.

Ignore the below example, and see the edit at the bottom.
One thought I had was being able to merge locals from a parent.hcl file.

For example:

root level account.hcl

locals {
  account_tags = { 
     "environment"  = "dev"
     "account_alias"  = "dev-app"
  }
}

mid-level app.hcl file

locals {
  app_tags = { 
     "app"  = "local_app"
     "type" = "local_type"
     "appid= "appid"
  }
}

leaf level resource terragrunt.hcl

locals {
  resource_tags = { 
     "app"  = "local_app"
     "type" = "local_type"
  }
  tags = merge(
     local.account_tags,
     local.app_tags,
     local.resource_tags
  )
}

I think the get_input/hcldecode helper functions might accomplish this. I think using the dependency functionality might make some things more complicated though I'd have to do some testing around it to see if it would work.

Thanks

Edit: Actually in thinking about it some more, I don't think my above solution would be ideal. I think it would be preferable to explicitly pull in a specific parent directory file. I think the following would be better.

locals {
  account_vars = hcldecode(file("${get_terragrunt_dir()}/${find_in_parent_folders("account.hcl")}"))
  app_vars = hcldecode(file("${get_terragrunt_dir()}/${find_in_parent_folders("app.hcl")}"))

  tags = merge(
       local.account_vars["tags"],
       local.app_vars["tags"],
  }
}

Would the hcldecode be able to parse any block from the account.hcl file. It seems like get_input would only be able to look at the inputs block, so I'm not sure what additional functionality would be needed from the hcldecode.

@yorinasub17
Copy link
Contributor

The idea of hcldecode would be to decode an hcl file into a map, similar to how yamldecode and jsondecode works.

One thought I had was being able to merge locals from a parent.hcl file.

I know you realized this wasn't ideal, but FWIW, we discussed a similar construct called globals that does this. See the thread here: #814

@rdebrandt
Copy link

Thanks for the link. That is definitely similar to what I was thinking. In the end any solution that allows for eplicit merging of variables/maps, and only using hcl files would be ideal.

@jakauppila
Copy link
Contributor

Very interested in this as well as; it looks like #858 will likely address this sort of functionality?

@collinstevens
Copy link

collinstevens commented Jan 7, 2020

As a matter of providing a use case where something like this is required, here is my example.

My Terraform deploys an Azure Function which takes a app_settings block which is a map(string). I am using the pattern from Infrastructure Live Example where the inputs are merged at different levels of the directory.

For my simple example, I have the following directory structure.

app/
  common.yaml
  terragrunt.hcl
  qa/
    environment.yaml
    terragrunt.hcl
  prod/
    environment.yaml
    terragrunt.hcl

The environment.yaml and common.yaml files are merged. There are app_settings blocks in the environment.yaml and common.yaml files. The common.yaml contains app_settings common to all environments, but it's app_settings block is replaced by the more specific environment.yaml file instead of being recursively merged.

The ability to opt-in/opt-out to a recursive merge would be much appreciated. This has broken my currently workflow as it has for @justicel as it was quite elegant to have it recursively merge before; it just worked.

It also appears there is a related ticket opened here hashicorp/terraform#16517

If anything, a get_input function as suggested by @brikis98 would be a less elegant fix than a recursive merge.

If anyone needs a temporary workaround, this is how I solved it.

# root level terragrunt.hcl
locals {
  default_yaml_path = find_in_parent_folders("empty.yaml")

  common_inputs      = yamldecode(file("${get_terragrunt_dir()}/${find_in_parent_folders("common.yaml", local.default_yaml_path)}"))
  environment_inputs = yamldecode(file("${get_terragrunt_dir()}/environment.yaml"))
  combined_inputs    = merge(local.common_inputs, local.environment_inputs)

  common_app_settings      = lookup(local.common_inputs, "app_settings", {})
  environment_app_settings = lookup(local.environment_inputs, "app_settings", {})
  app_settings             = merge(local.common_app_settings, local.environment_app_settings)

  inputs = merge(local.combined_inputs, { app_settings = local.app_settings })
}

inputs = local.inputs

@yorinasub17
Copy link
Contributor

I wrote up an RFC that introduces import blocks. This has the potential to address the use case described here, so would love feedback from those following this issue to see if it makes sense.

@collinstevens
Copy link

@yorinasub17 I skimmed through your proposal, I will go through it more in-depth when I have time, but I don't see it solving this use case. Maybe I just missed it, but I actually think this is more cumbersome and verbose rather than DRY.

I don't believe I would use this proposal over my current configuration and still would like a way to merge maps. It still appears as you have to merge the inputs manually and would be required to pick out the maps as I do in my example above and in this post.

Below I've posted a real root terragrunt.hcl I'm using in a monorepo with many different applications. As you can see, I'm essentially using convention over configuration. I believe with your proposal I would have to declare the inheritance of parent configurations all within the application level terragrunt.cl. I've also provided an image of the directory for context, as well as an example of an application level terragrunt.hcl

.\tf\terragrunt.hcl

# ---------------------------------------------------------------------------------------------------------------------
# TERRAGRUNT CONFIGURATION
# Terragrunt is a thin wrapper for Terraform that provides extra tools for working with multiple Terraform modules,
# remote state, and locking: https://github.com/gruntwork-io/terragrunt
# ---------------------------------------------------------------------------------------------------------------------

# Configure Terragrunt to automatically store tfstate files in an Azure Blob 
remote_state {
  backend = "azurerm"
  config = {
    resource_group_name  = "rg-meta-terraform"
    storage_account_name = "stterraformstate002"
    container_name       = "tfstate"
    key                  = "${path_relative_to_include()}/terraform.tfstate"
  }
}

# ---------------------------------------------------------------------------------------------------------------------
# GLOBAL PARAMETERS
# These variables apply to all configurations in this subfolder. These are automatically merged into the child
# `terragrunt.hcl` config via the include block.
# ---------------------------------------------------------------------------------------------------------------------

locals {
  default_yaml_path = find_in_parent_folders("empty.yaml")

  # Get the environment application variable overrides, i.e. apps/function-apps/qa/order-processing/application.yaml
  environment_application_file_path = "${get_terragrunt_dir()}/application.yaml"

  # Get the current application name, i.e. apps/function-apps/qa/order-processing/application.yaml would be order-processing
  application_name = basename(dirname(local.environment_application_file_path))

  # Get the current environment name, i.e. apps/function-apps/qa/order-processing/application.yaml would be qa
  environment_name = basename(dirname(dirname(local.environment_application_file_path)))

  # Get the common variable overrides, i.e. apps/common.yaml
  common_file_path = "${get_terragrunt_dir()}/${find_in_parent_folders("common.yaml", local.default_yaml_path)}"

  # Get the top-level environment variable overrides, i.e. apps/qa.yaml
  common_environment_file_path = "${get_terragrunt_dir()}/${find_in_parent_folders("${local.environment_name}.yaml", local.default_yaml_path)}"

  # Get the infraustructure type variable overrides, i.e. apps/function-apps/infrastructure.yaml
  infrastructure_file_path = "${get_terragrunt_dir()}/${find_in_parent_folders("infrastructure.yaml", local.default_yaml_path)}"

  # Get the application for an infrastructure variable overrides, i.e. apps/function-apps/order-processing.yaml
  infrastructure_application_file_path = "${get_terragrunt_dir()}/${find_in_parent_folders("${local.application_name}.yaml", local.default_yaml_path)}"

  # Get the environment variable overrides for an infrastructure, i.e. apps/function-apps/qa/environment.yaml
  environment_file_path = "${get_terragrunt_dir()}/${find_in_parent_folders("environment.yaml", local.default_yaml_path)}"

  # Fold over the inputs from the root to the most specific, i.e. apps/qa.yaml overrides apps/common.yaml, apps/order-processing/app.yaml overrides app/qa.yaml etc.
  common_inputs                     = yamldecode(file(local.common_file_path))
  common_environment_inputs         = yamldecode(file(local.common_environment_file_path))
  infrastructure_inputs             = yamldecode(file(local.infrastructure_file_path))
  infrastructure_application_inputs = yamldecode(file(local.infrastructure_application_file_path))
  environment_inputs                = yamldecode(file(local.environment_file_path))
  environment_application_inputs    = yamldecode(file(local.environment_application_file_path))

  # Left inputs are merged with right inputs, preferring right inputs
  combined_inputs = merge(merge(merge(merge(merge(local.common_inputs, local.common_environment_inputs), local.infrastructure_inputs), local.infrastructure_application_inputs), local.environment_inputs), local.environment_application_inputs)

  # Fold over the app_settings from the root to the most specific, i.e. apps/qa.yaml overrides apps/common.yaml, apps/order-processing/app.yaml overrides app/qa.yaml etc.
  # Since app_settings is a map, it is not merged in the generic file merge, it is only replaced, so we must manually merge
  common_app_settings                     = lookup(local.common_inputs, "app_settings", {})
  common_environment_app_settings         = lookup(local.common_environment_inputs, "app_settings", {})
  infrastructure_app_settings             = lookup(local.infrastructure_inputs, "app_settings", {})
  infrastructure_application_app_settings = lookup(local.infrastructure_application_inputs, "app_settings", {})
  environment_app_settings                = lookup(local.environment_inputs, "app_settings", {})
  environment_application_app_settings    = lookup(local.environment_application_inputs, "app_settings", {})

  # Left app settings are merged with right app settings, preferring right app settings
  combined_app_settings = merge(merge(merge(merge(merge(local.common_app_settings, local.common_environment_app_settings), local.infrastructure_app_settings), local.infrastructure_application_app_settings), local.environment_app_settings), local.environment_application_app_settings)

  directory_inputs = {
    environment    = local.environment_name
    name           = local.application_name
    key_vault_name = local.application_name
  }

  inputs = merge(merge(local.directory_inputs, local.combined_inputs), { app_settings = local.combined_app_settings })
}

inputs = local.inputs

.\tf\apps\function-apps\qa\order-processing\terragrunt.hcl

terraform {
  source = "../../../../modules//order-processing"
}

include {
  path = find_in_parent_folders()
}

image

@yorinasub17
Copy link
Contributor

yorinasub17 commented Jan 30, 2020

Thanks for sharing your use case! There is a lot going on there and I didn't have a whole lot of time to deep dive into your config, but I think what I pieced out in terms of feature list was:

  • Automatic deep merging of terragrunt config
  • Conditional imports
  • [Potential?] Ability to consolidate complex logic based on folder hierarchy. E.g all the import logic should be in the root, and then the child all import the root file.

The first one makes sense, and I had actually planned merge = true to be a deep merge by default as it is necessary to implement the DRY remote state config use case described in the RFC. However, I had missed #744 (comment), which also makes sense so I adjusted the RFC to be explicit about a deep_merge.

The second one only makes sense if we support the third bullet point, although I am not sure I want to go that route. It certainly does make the child config more terse, but at the expense of making it harder to parse. And I think you can still go pretty far in terms of terseness with just deep merges and imports. Of course, this is edging towards flame war territory as you mention (convention vs configuration).


Given that, if you were to implement your original simpler use case with the imports feature, it would be as follows:

app/
  terragrunt.hcl
  qa/
    terragrunt.hcl
    services/
      terragrunt.hcl
  prod/
    terragrunt.hcl
    services/
      terragrunt.hcl

app/terragrunt.hcl

inputs = {
  # your common inputs
}

app/qa/terragrunt.hcl

import "common" {
  config_path = find_in_parent_folders()
  deep_merge = true
}

inputs = {
  # environment specific config
}
# Note that this inputs is a deep merge of the inputs specified here and the inputs from the root terragrunt.hcl

app/qa/services/terragrunt.hcl

import "environment" {
  config_path = find_in_parent_folders()
  deep_merge = true
}

This pushes some of the complexity around merges and imports to the middle configuration. So yes, there is going to be some repetition in the middle layers around the import logic and I can understand if you disagree with the design principles here.

FWIW, I think the missing feature here to get what you want is find_in_parent_folders that works in the context of the config that is importing, and not the current config (e.g a find_in_parent_folders_from_importing_config). I still would like to hold some of the design principles, but from a practical perspective, this may be necessary to support all the use cases that terragrunt currently supports. I've added this as an open question to discuss in the RFC.

@collinstevens
Copy link

@yorinasub17 not trying to start a flame war over convention vs configuration! Trying to provide as much feedback as possible ;).

deep_merge is definitely what I was looking for. I would actually like to see deep_merge as a separate function if possible, maybe this could be spun out as a function in the public API as it would have to be implemented anyway to support your proposal.

As far as my use case, there would be too much repetition in my opinion to spread out all of the importing logic which I have consolidated in the root file once for the entire directory. Although, if I didn't have multiple applications in one repository, I do believe I would prefer proposal because I wouldn't have so many leaf nodes, but would rather have a pretty straight tree instead of a wide bottom.

I bundle my Terraform with my application code in the same repository. Most of my repositories only have one application deployment and one logical Terraform deployment (multiple environments/regions), and my approach is overly verbose for this and would be simplified by your proposal. 👍

@yorinasub17
Copy link
Contributor

I would actually like to see deep_merge as a separate function if possible, maybe this could be spun out as a function in the public API as it would have to be implemented anyway to support your proposal.

Yup agreed that this should be straightforward once the deep merge functionality is implemented for imports.

As far as my use case, there would be too much repetition in my opinion to spread out all of the importing logic which I have consolidated in the root file once for the entire directory.

Yup I figured that from the full use case. Do you think this would change if we implemented find_in_parent_folders_from_importing_config? Then you can do in the root config:

import "common" {
  config_path = find_in_parent_folders_from_importing_config("common.hcl")
  deep_merge = true
}

import "overrides" {
  config_path = find_in_parent_folders_from_importing_config("override.hcl")
  deep_merge = true
}

# etc

From there, the child config just needs to import the root config to get all the imports.

Not promising that we would implement this (still an open question: would like to see some more use cases that would suggest this need), but certainly would be a data point in favor of it.


not trying to start a flame war over convention vs configuration! Trying to provide as much feedback as possible ;).

Sorry I meant that in the most light hearted way possible. Apologies if that came off negative! I really appreciate this feedback 👍

@collinstevens
Copy link

@yorinasub17 find_in_parent_folders_from_importing_config would be rock solid dude. As you can see from my root configuration, I'm essentially doing it manually. I have several levels of overrides I have to merge and then I merge the app_settings maps I have within those override files. find_in_parent_folders_from_importing_config with deep_merge would get rid of most of my code.

@yorinasub17
Copy link
Contributor

We now have multiple include blocks, exposed includes, and include deep merge, which combined should handle this use case (https://terragrunt.gruntwork.io/docs/reference/config-blocks-and-attributes/#include). Closing as solved.

If the original use case still can't be addressed with the new functionality, please open a new issue. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

6 participants