Simplify Terraform By Generating Configurations

Terraform is an awesome tool. To make it more awesome though we have wrapped it with some custom Ruby ERB templating to generate our terraform configurations from Yaml configurations.

Terraform uses a declarative language. You describe the state you want and it figures out how to get there. The declarative nature of Terraform does not afford us the same control that a language like Ruby can provide, which is fine, but I have found that I end up managing _massive_ Terraform configurations. It's easy to make mistakes. Generating our Terraform configurations allows us to create more robust services in a shorter amount of time. It's much easier to edit an 80 line yaml file than a 5000 line Terraform configuration. 

We have a script, let's call it "Terraform Generator", or `tg`. We pass to `tg` an environment configuration like `develop`. 

tg develop


This will generate the terraform configuration `terraform/develop/main.yml` from a yaml file at `configs/develop.yml`

The code to generate the file is pretty simple. By running the generated plan through `terraform fmt` we can also do some validation to ensure it's not broken! Simply put we read in the environments yaml file, parse an erb template with the content from that yaml file, output a terraform configuration file, then validate that file. 

`tg` does basically the following:

  def output_terraform
    renderer = ERB.new(File.open("templates/#{@tf_template}").read)
    rendered = renderer.result(@context)

    out_file = File.open("#{@output_directory}/main.tf", 'w')
    out_file.puts(rendered)

    format_terraform
  end

  def format_terraform
    system "terraform fmt #{@output_directory}"
  end

  def create_output_directory
    directory = "terraform/#{@config['config_src']}"

    unless File.directory?(directory)
      FileUtils.mkdir_p(directory)
    end

    directory
  end

  def main(*argv)
    config_src = argv.last

    @config           = YAML.load_file("configs/#{config_src}.yaml")
    @tf_template      = 'main.tf.erb'
    @output_directory = create_output_directory
    @context          = binding

    output_terraform
  end

  main(*ARGV)


The yaml file may look something like:

version: 1

region: us-west-1

tfstate: develop

cluster:
  name: DEVELOP

load_balancers:
  frontend:
    ssl_certs:
      - example.com
  backend:
    ssl_certs:
      - example.com

ssl_certs:
  default:
    domain_name: 'example.com'
    subject_alternative_names:
      - '*.example.com'

services:

  my_service:
    task_count: 2
    cpu: 256
    memory: 512
    https:
      load_balancer: frontend
      url: my_service.example.com
      health_check:
        path: /
    autoscaling:
      min: 2
      max: 8
      cpu: 75 

  my_other_service:
    task_count: 2
    cpu: 256
    memory: 512
    https:
      load_balancer: backend
      url: my_other_service.example.com
      health_check:
        path: /
    autoscaling:
      min: 2
      max: 4
      cpu: 60


The ERB template:

terraform {
  required_version = ">= 0.14.5"

  backend "s3" {
    bucket = "terraform-state"
    key    = "<%= @config['tfstate'] %>/terraform.tfstate"
    region = "<%= @config['region'] %>"

    # Force encryption
    encrypt = true
  }

  required_providers {
    aws = {
      version = "~> 3.24.1"
    }
  }
}

provider "aws" {
  region = "<%= @config['region'] %>"
}
<% @config['services'].each do |service, values| %>
   ...
<% end %>

... etc ...