Terragrunt

ArCo architecture uses Terragrunt instead of directly using Terraform to deploy the infrastructure. Terragrunt is a tool that solved some lacks of Terraform and acts like a wrapper around it. Some of those lacks -like not having a way to configure the state as code or locking the state- have already been solved in posterior versions of Terraform. But Terragrunt is still needed to get a clean architecture and apply a lot of the good practices we talked about in the Terraform section.

For example Terraform doesn’t allow to use variable in the bucket, region or dynamodb_table. That means that we would have to copy that same configuration in every module in which we want an independent state file. However, with Terragrunt we can achieve this and many more things. This way we can avoid a lot of duplicated code in the components configuration. This is how it is done with Terragrunt but it doesn’t work with the last version of Terraform.

terraform {
    backend "s3" {
    bucket         = var.bucket
    key            = "global/s3/terraform.tfstate"
    region         = var.region

    dynamodb_table = var.dynamodb_table
    encrypt        = true
  }
}

Structure

The structure in an ArCo Terraform project is the same as we have seen before. The only change is that we are using Terragrunt files instead of those from Terraform. The final structure looks similar to the following:

terragrunt.hcl (1)
global
    iam
        terragrunt.hcl
    dlm
        terragrunt.hcl
...

nlv
    ci-cd
        vpc
            bastion
                terragrunt.hcl (2)
            dns
                terragrunt.hcl
            network
                terragrunt.hcl
        services
            jenkins
                terragrunt.hcl
            nexus
                terragrunt.hcl

...
1 Terragrunt file containing the global configuration for all environments.
2 Terragrunt file containing the specific configuration for each component.

We only need to replace the main.tf files with the terragrunt.hcl using their own syntax to express the same concept.

Terragrunt admits only up to two configuration files levels: one for global variables and another for the specific configuration of the component.

Global configuration

The global configuration file contains the generic variables that doesn’t change in the different environments such as as the configuration to stare the Terraform state in a remote bucket.

remote_state { (1)
  backend = "s3"

  config = {
    encrypt        = true
    bucket         = local.remote_bucket
    key            =  "${path_relative_to_include()}/terraform.tfstate"
    region         = "eu-central-1"
    dynamodb_table = "vectordigital-terraform-locks"
    profile        = local.profile
  }
}

locals { (2)
  remote_bucket = format("vectordigital-%s-terraform-state", get_env("env_account", "nlv"))
  profile = format("vectordigital-%s", get_env("env_account", "nlv"))
}

inputs = { (3)
  aws_region                      = "eu-central-1"
  bucket                          = local.remote_bucket
}


terraform { (4)
  extra_arguments "aws_profile" {
    commands = get_terraform_commands_that_need_vars()
    env_vars = {
      AWS_PROFILE = local.profile
    }
  }
}
1 remote_state defines the configuration to persiste the Terraform state in a way that is shared between all components.
2 locals contains the local variables used in different places across the file. In this block we can find the AWS profile and the name of the bucket where the state will be stored.
3 input section adds the variable that will be inherited in the components configuration. In our case it will be the region where we want them to be deployed and the local variable with the name of the state bucket.
4 terraform defines the configuration that Terragrunt will pass to Terraform. In this case we can see the AWS profile used in the deployment.

There are a couple of things we have to emphasize about this file. An environment variable env_account have been defined to indicate the environment in which we are working. This variable is used to select the AWS profile that will be used as the bucket name used by Terragrunt to store the state. In our case the only two possible values are nlv and live. This way we are sure that the deployments and states of live and no live environments are different.

If no ENV_ACCOUNT variable is explicitly defined its default value will be nlv.
terragrunt.hcl (1)
global
    iam
        terraform.tfstate
    dlm
        terraform.tfstate
...

nlv
    ci-cd
        vpc
            bastion
                terraform.tfstate
            dns
                terraform.tfstate
            network
                terraform.tfstate
        services
            gitlab
                terraform.tfstate
            gitlab_ssh_listener
                terraform.tfstate

...
With Terragunt we no longer need to create the S3 resources or the Dynamo database by ourselves. If it doesn’t exist Terragunt will ask if we want it to create them and it will do it as long as we have the needed privileges.

Components configuration

The component configuration file will reference the Terraform module that we will be using and its input parameters.

terraform {
    source = "git@{git-url}:infrastructure/modules/aws/networking/module-base-vpc.git?ref=v0.0.1" (1)
}

include {
    path = find_in_parent_folders() (2)
}


inputs = { (3)
    name           = "foo"
    dns_suffix     = "foo.com"
    vpngw_asn      = ""
    cidr_block     = "10.200.64.0/20"
    subnets        = {
        red = 1
        amber = 1
        orange = 1
        green = 0
    }
    common_tags    = {
        stack_id = "xxxxxx"
        owner    = "john.dole"
    }
}
1 terraform section indicates the version of the module Terragunt is going to use.
2 include section indicates the global configuration file we are importing to use.
3 inputs section contains all the input parameters needed to use the module.
find_in_parent_folders function searches up the directory tree from the current terragrunt.hcl file and returns the relative path to the first terragrunt.hcl in a parent folder.

Additional variables in environments and subenvironments

As we said before, Terragrunt has a restriction in the number of configuration levels. There are only two levels but sometimes we need some variables to be under the environment and subenvironment level. ArCo solves this by using two yaml files: the first in the environment level and the second in the subenvironment level. In those files we define the common variables for the level their in. The name of those files are env_vars.yml and common_vars.yaml respectively.

This is an example of an env_vars in which we declare the used AMIs, the allowed subnetworks among other details.

bastion_ami: "ami-06568c8c466adbf0d"
haproxy_ami: "ami-06568c8c466adbf0d"
asg_ami: "ami-025f30573236c985a"
ci_ami: "ami-0badcc5b522737046"
public_cidr_blocks_allowed: ["213.37.5.105/32", "212.230.117.127/32", "83.40.143.144/32"]
cidr_blocks_vector_management: ["10.27.0.0/21", "172.21.0.0/16", "172.22.0.0/16", "172.24.0.0/16", "192.168.29.0/24"]
cidr_blocks_vector: ["10.27.0.0/21", "172.16.0.0/12"]
disable_api_termination: false

On the other hand, a common_vars.yaml looks like this. Here we define variables like the name of the vpc or the path to remote states.

name: "vpc-foo"
dns_suffix: "foo.com"
common_tags:
  Project: "FOO001"
path_to_vpc_remote_state: "nlv/foo/vpc/subnets"
path_to_dns_remote_state: "nlv/foo/vpc/dns"
path_to_bastion_remote_state: "nlv/foo/vpc/bastion"

These files should be imported in the component configuration file just like this:

terraform {
    source = "git@{git-url}:infrastructure/modules/aws/networking/module-base-bastion.git?ref=v0.0.1"
}

include {
    path = find_in_parent_folders()
}

dependencies {
    paths = ["../subnets", "../dns"]
}

locals {
    common_vars = yamldecode(file("${get_terragrunt_dir()}/${find_in_parent_folders("common_vars.yaml")}")) (1)
    env_vars = yamldecode(file("${get_terragrunt_dir()}/${find_in_parent_folders("env_vars.yaml")}"))
    key_pair      = {
    name = local.common_vars.name
    path = "${get_terragrunt_dir()}/../../files/cicd.pub"
}
}

inputs = {
    path_to_vpc_remote_state      = local.common_vars.path_to_vpc_remote_state
    path_to_dns_remote_state      = local.common_vars.path_to_dns_remote_state
    vpc_name                    = local.common_vars.name
    ami                         = local.env_vars.bastion_ami
    disable_api_termination     = local.env_vars.disable_api_termination
    public_cidr_blocks_allowed  = local.env_vars.public_cidr_blocks_allowed
    public_access               = true
    key_pair                    = local.key_pair
    dns_suffix                  = local.common_vars.dns_suffix
    common_tags                 = local.common_vars.common_tags
}
1 in the locals section we are using the funtion yamldecode to import the needed file. In this case we are getting the common variables for the subenvironment.

Modules state configuration

To make Terraform modules to inherit the state configuration of Terragrunt we only need to add a terraform block with an empty backend configuration. This way Terragrunt wll add the configuration by itself, getting it from the global configuration previously defined.

provider "aws" {
  region = var.aws_region
}

terraform { (1)
  # The configuration for this backend will be filled in by Terragrunt
  backend "s3" {}

  # The latest version of Terragrunt (v0.19.0 and above) requires Terraform 0.12.0 or above.
  required_version = ">= 0.12.0"
}

Usage

To use Terragrunt we need to have installed Terraform before. We can follow these guides to install both of them:

Before executing Terragrunt we need to configure the environment. The first step is to add the AWS profiles used to deploy the infrastructure. We can do this by editing the ~/.aws/config file and adding the following configuration:

[foo-nlv]
region = eu-central-1
output = json

[foo-live]
region = eu-central-1
output = json

The credentials for each AWS account will be stored in the ~/.aws/credentials file.

[foo-nlv]
aws_access_key_id = SDFDSAFSFDSAFASDFASDFDS
aws_secret_access_key = nsldfjasdf324sdf32f3242fds

[foo-live]
aws_access_key_id = JKHKHGJFGDHFDGDSDGDFGS
aws_secret_access_key = 3294023sdjsflkjfldsjf

To choose the actual environment to deploy in, we need to declare the EVN_ACCOUNT variable with one of the values live or nlv. By default, if no value is specified, nlv will be used.

export ENV_ACCOUNT=live

Being a wrapper of Terraform, Terragrunt keeps the same commands it has but changing the word terraform with terragrunt.

  • terragrunt init: it initialize the working directory. This command isn’t necessary if the autoinitialization of Terragrunt is enabled.

  • terragrunt plan: it analyses and simulates the execution and returns what resources will be created, updated or deleted.

  • terragrunt apply: it deploys the new configuration in the specified environment.

  • terragrunt import: it imports the Terraform state of an externally created resource.

  • terragrunt destroy: it destroys all resources created by the component.

Executing a module with a local implementation

Sometimes we need to test a change in a module and we want to do this in our local machine before committing the change into the repository. We can manage this by overriding the source attribute defined in the component using the --terragrunt-source parameter and the relative path to the module.

terragrunt apply --terragrunt-source ../../../../modules/module-base-vpc/