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/