Sandeep

Terragrunt - Multiple Buckets remote_state

Terragrunt - Multiple Buckets remote_state

Approach to create different Remote-Buckets in same environment in Terragrunt for diferent product and account.

Terragrunt


Terragrunt is a thin wrapper that provides extra tools for keeping your configurations DRY, working with multiple Terraform modules, and managing remote state.

We are using Terragrunt for all our infrastructure code that we deploy in different environments.
Terragrunt helps us too, to keep the Terraform code DRY across all of our environments.

Terragrunt - remote_state


Terragrunt uses remote_state block to configure and set up the remote state configuration of Terraform code.
A basic block of remote_state that one might keep in terragrunt.hcl looks like below

remote_state {
    backend = "s3"
    config = {
      bucket = "your_remote_bucket"
      key    = "path/to/your/key"
      region = "us-east-1"
    }
  }

Above piece of code snippet will create a remote bucket where terraform state file will be stored.

This code works well if you have a single account, and many environments. It will keep your code DRY and create a dedicated bucket per environment.

But what if you want to maintain same code, same environments but with different accounts.

Terragrunt - Current remote_state


Consider this directory structure

.
├── dev
│   ├── common
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
│   ├── ecs
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
│   ├── kms
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
├── common.yaml
└── terragrunt.hcl

and code for terragrunt.hcl

locals {
  team          = "team-a"
  infra         = "infra-project-a"
  unit          = "mobile"
  cloud         = "hybrid"
  aws_region    = "eu-west-1"
  common        = yamldecode(file(find_in_parent_folders("common.yaml")))
  tags          = merge(local.common.base_tags,)
  secrets_path  = "${get_terragrunt_dir()}/secrets.yaml"
  secrets       = yamldecode(fileexists(local.secrets_path) ? file(local.secrets_path) : "{}")
}

remote_state {
  backend               = "s3"
  config                = {
    encrypt             = true
    bucket              = join("-", [ lower(local.team), lower(local.infra), lower(local.unit), lower(local.cloud), lower(local.aws_region), "tfstate", ])
    key                 = "${local.unit}.tfstate"
    region              = local.aws_region
    dynamodb_table      = join("-", [lower(local.team),lower(local.infra),lower(local.unit),lower(local.cloud),lower(local.aws_region),"tflock", ])
    s3_bucket_tags      = local.tags
    dynamodb_table_tags = local.tags
  }
}

From above code, AWS S3 will be created by Terragrunt using remote_state block code.
Name of the S3 will be a join of all these local variables i.e. team-a-infra-project-a-mobile-hybrid-eu-west-1-tfstate.

bucket = join("-", [ lower(local.team), 
    lower(local.infra), 
    lower(local.unit), 
    lower(local.cloud), 
    lower(local.aws_region), 
    "tfstate", 
    ])

File common/terragrunt.hcl just need to have include block to get parent folder.

include {
  path = find_in_parent_folders()
}

This works well if we are working on same Account.

What if we want to override top level terragrunt.hcl local parameters so that name of bucket in new Account will be different.

Terragrunt - New remote_state


Consider, we have new AWS account and we want to deploy same code in new AWS account. We created the below directories with terragrunt.hcl.

But when we run the code in new AWS account, we will get an error saying S3 already exists.

Since S3 shares DNS namespace, name of S3’s has to be unique across globe.

.
├── dev
│   ├── common
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
│   ├── ecs
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
│   ├── kms
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
│   ├── common-new-account
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
│   ├── ecs-new-account
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
│   ├── kms-new-account
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
├── common.yaml
└── terragrunt.hcl

We need to override one of the below params present in bucket join function so that unique S3 name can be generated.

bucket = join("-", [ lower(local.team), 
    lower(local.infra), 
    lower(local.unit), 
    lower(local.cloud), 
    lower(local.aws_region), 
    "tfstate", 
    ])

Terragrunt - locals


Terragrunt locals is a block, and there is not a way currently to merge the map from child level directory into the top level.

So, if we want to override the locals in child terragrunt.hcl, parent terragrunt.hcl will not be updated.

Terragrunt locals will not help us.

Terragrunt - Override


I will be overriding below params so that remote_state makes a unique S3 in my new account.

bucket = join("-", [ lower(local.team), 
    lower(local.infra), 
    lower(local.unit), 
    lower(local.cloud), 
    lower(local.aws_region), 
    "tfstate", 
    ])

I will create overrides.yaml in child product directory as shown below and can contain params what we want to override in parent terragrunt.hcl.

.
├── dev
│   ├── common
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
│   ├── ecs
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
│   ├── kms
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
│   ├── common-new-account
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
│   │   └── overrides.yaml
│   ├── ecs-new-account
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
│   │   └── overrides.yaml
│   ├── kms-new-account
│   │   ├── secrets.yaml
│   │   └── terragrunt.hcl
│   │   └── overrides.yaml
├── common.yaml
└── terragrunt.hcl
base_s3:
  a_team: "team-a"
  b_infra: "infra-project-b"
  c_unit: "desktop"
  d_cloud: "onprem"
  e_aws_region:  "eu-west-1"

Now to override the params in parent terragrunt.hcl, lets include our overrides.yaml in parent terragrunt.hcl as shown below.

locals {
  team            = "team-a"
  infra           = "infra-project-a"
  unit            = "mobile"
  cloud           = "hybrid"
  aws_region      = "eu-west-1"
  common          = yamldecode(file(find_in_parent_folders("common.yaml")))
  tags            = merge(local.common.base_tags,)
  secrets_path    = "${get_terragrunt_dir()}/secrets.yaml"
  secrets         = yamldecode(fileexists(local.secrets_path) ? file(local.secrets_path) : "{}")
  overrides_path  = "${get_terragrunt_dir()}/overrides.yaml"
  overrides       = yamldecode(fileexists(local.overrides_path) ? file(local.overrides_path) : "{}")
  overridden      = fileexists(local.overrides_path) ? merge(local.overrides.base_s3) : {}
}

remote_state {
  backend               = "s3"
  config                = {
    encrypt             = true
    bucket              = fileexists(local.overrides_path) ? lower(format("%s-tfstate", join("-", values(local.overridden)))) : join("-", [ lower(local.team), lower(local.infra), lower(local.unit), lower(local.cloud), lower(local.aws_region), "tfstate", ])
    key                 = "${local.unit}.tfstate"
    region              = local.aws_region
    dynamodb_table      = join("-", [lower(local.team),lower(local.infra),lower(local.unit),lower(local.cloud),lower(local.aws_region),"tflock", ])
    s3_bucket_tags      = local.tags
    dynamodb_table_tags = local.tags
  }
}

You can see our bucket expression changed a bit. If the file exists overrides.yaml in current child directory, name of bucket will come from file overrides.yaml params.

If overrides.yaml doesn’t exists i.e in case of older account child directory, same old logic to join locals will run.

bucket = fileexists(local.overrides_path) 
    ? 
    lower(format("%s-tfstate", join("-", values(local.overridden)))) 
    : 
    join("-", [ 
          lower(local.team), 
          lower(local.infra), 
          lower(local.unit), 
          lower(local.cloud), 
          lower(local.aws_region), 
          "tfstate", 
          ]
        )

Conclusion


Terragrunt is great tool to make terraform code very DRY. But to make terragrunt code change according to your use-case, we need to do some tricks with it.

We faced the same issue and solved that too. Of course, there are more than 1 solution to solve same problem. But for us, this suited the best.