December 12, 2021

Day 12 - Terraform Refactoring

By: Bill O'Neill (@woneill)
Edited by: Kerim Satirli (@ksatirli)

Terraform is "Infrastructure as Code" and like all code, it is beneficial to review and refactor to:

  • improve code readability and reduce complexity
  • improve the maintainability of the source code
  • create a simpler, cleaner, and more expressive internal architecture or object model to improve extensibility

This article outlines the approaches that have helped my teams when refactoring Terraform code bases.

Convert modules to independent Git repositories

If your Terraform Git repository has grown organically, you will likely have a monorepo structure complete with embedded modules, similar to this:

$ tree terraform-monorepo/
.
├── README.md
├── main.tf
├── variables.tf
├── outputs.tf
├── ...
├── modules/
│   ├── moduleA/
│   │   ├── README.md
│   │   ├── variables.tf
│   │   ├── main.tf
│   │   ├── outputs.tf
│   ├── moduleB/
│   ├── .../

Encapsulating resources within modules is a great step, but the monorepo structure makes it difficult to iterate on individual module development, down the line.

Splitting the modules into independent Git repositories will:

  • Enable module development in an isolated manner
  • Support re-use of module logic in other Terraform code bases, across your organization
  • Enable publishing to public and private Terraform Registries

Here's a process that you can follow to make a module a stand-alone Git repository while preserving the historical log messages. The steps are examples of how to extract moduleA from the above file tree into its own git repository.

  1. Clone the Terraform Git repository to a new directory. I recommend naming the directory after the module you plan on converting.
    git clone <REMOTE_URL> moduleA
  2. Change into the new directory:
    cd moduleA
  3. Use git filter-branch to split out the module into a new repository..
    FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch --subdirectory-filter modules/moduleA -- --all

    Note that we're squelching the warning about filter-branch. See the filter-branch manual page for more details if you're interested

  4. Now your directory will only contain the contents of the module itself, while still having access to the full Git history.

    You can run git log to confirm this.
  5. Create a new Git repository and obtain the remote URL for it, then update the origin in the filtered repository:
    git remote set-url origin <NEW_REMOTE_URL>
    git push -u origin main
    
  6. Tag the repo as v1.0.0 before making any changes

       
    git tag v1.0.0
    git push --tags
    
  7. Now that the new repository is ready to be used, update the existing references to the module to use a source argument that points to the tag that you just created.

    The “Generic Git Repository” section in Terraform's Module Sources documentation has more details on the format.

    Replace lines such as

    source = "../modules/moduleA"


    with

    source = "git::<NEW_REMOTE_URL>?ref=v1.0.0"
    
  8. Alternatively, publishing your module to a Terraform registry is an option (but this is outside the scope of this article).
  9. Once all source arguments that previously pointed to the directory path have been replaced with references to Git repositories or Terraform registry references, delete the directory-based module in the original Terraform repository.

Update version constraints with tfupdate

Masayuki Morita's tfupdate utility can be used to recursively update version constraints of Terraform core, providers, and modules.

As you start refactoring modules and bumping their version tags, tfupdate becomes an invaluable tool to ensure all references have been updated.

Some examples of tfupdate usage, assuming the current directory is to be updated:

  • Updating the version of Terraform core:
    tfupdate terraform --version 1.0.11 --recursive .
  • Updating the version of the Google Terraform provider:
    tfupdate provider google --version 4.3.0 --recursive .
  • Updating the version references of Git-based module sources can be done with the module subcommand, for example:
    tfupdate module git::<REMOTE_URL> --version 1.0.1 --recursive .

Test state migrations with tfmigrate

Many Terraform users are hesitant to refactor their code base, since changes can require updates to the state configuration. Manually updating the state in a safe way involves duplicating the state, updating it locally, then copying it back in place.

In addition to tfupdate, Masayuki Morita has another excellent utility that can be used to apply Terraform state operations in a declarative way while validating the changes, before committing them: tfmigrate

You can do a dry run migration where you simulate state operations with a temporary local state file and check to see if terraform plan has no changes after the migration., This workflow is safe and non-disruptive, as it does not actually update the remote state.

If the dry run migration looks good, you can use tfmigrate to apply the state operations in a single transaction instead of multiple, individual changes.

Migrations are written in HCL and use the following format:

migration "state" "test" {
  dir = "."
  actions = [
    "mv google_storage_backup.stage-backups google_storage_backup.stage_backups",
    "mv google_storage_backup.prod-backups google_storage_backup.prod_backups",
  ]
}

Each action line is functionally identical to the command you’d run manually such as terraform state <action> …. A full list of possible actions is available on the tfmigrate website.

Quoting resources that have indexed keys can be tricky. The best approach appears to be using a single quote around the entire resource and then escaping the double quotes in the index. For example:

actions = [
    "mv docker_container.nginx 'docker_container.nginx[\"This is an example\"]'",
]

Testing the state migrations can be done via tfmigrate plan <filename>. The output will show you what terraform plan would look like if you had actually carried out the state changes.

Applying the migration to the actual state is done via terraform apply <filename>. Note that by default, it will only apply the changes if the result from tfmigrate plan was a clean output.

If you still want to apply changes to a “dirty” state, you can do so by adding a force = true line to the migration file.

If you are using Terraform 1.1 or newer, there is now a built-in moved statement that works similarly to these approaches. I haven’t tested it out yet but it looks like a useful feature! I can see it being especially useful for users who may not have direct access to state files such as Terraform Cloud and Enterprise users or Atlantis users.

See the announcement in the 1.1 release as well the HashiCorp Learn tutorial for more details.

Ensure standards compliance with TFLint

According to its website, TFLint is a Terraform linter with a handful of key features:

  • Finding possible errors (like illegal instance types) for major Cloud providers (AWS/Azure/GCP)
  • Warning about deprecated syntax and unused declarations
  • Enforcing best practices and naming conventions

TFLint has a plugin system for including cloud provider-specific linting rules as well as updated Terraform rules. Setting up the list of rules can be done on the command line but it is recommended to use a config file to manage the extensive list of rules to apply to your codebase.

Here is a configuration file that enables all of the possible terraform rules as well as includes AWS specific rules. Save it in the root of your Git repository as .tflint.hcl then initialize TFLint by running tflint –init. Now you can lint your codebase by running tflint

config {
  module              = false
  disabled_by_default = true
}

plugin "aws" {
  enabled = true
  version = "0.10.1"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

rule "terraform_comment_syntax" {
  enabled = true
}

rule "terraform_deprecated_index" {
  enabled = true
}

rule "terraform_deprecated_interpolation" {
  enabled = true
}

rule "terraform_documented_outputs" {
  enabled = true
}

rule "terraform_documented_variables" {
  enabled = true
}

rule "terraform_module_pinned_source" {
  enabled = true
}

rule "terraform_module_version" {
  enabled = true
  exact = false # default
}

rule "terraform_naming_convention" {
  enabled = true
}

rule "terraform_required_providers" {
  enabled = true
}

rule "terraform_required_version" {
  enabled = true
}

rule "terraform_standard_module_structure" {
  enabled = true
}

rule "terraform_typed_variables" {
  enabled = true
}

rule "terraform_unused_declarations" {
  enabled = true
}

rule "terraform_unused_required_providers" {
  enabled = true
}

rule "terraform_workspace_remote" {
  enabled = true
}

pre-commit

Setting up git hooks with the pre-commit framework allows you to automatically run TFLint, as well as many other Terraform code checks, prior to any commit.

Here is a sample .pre-commit-config.yaml that combines Anton Babenko's excellent collection of Terraform specific hooks with some out-of-the-box hooks for pre-commit. It ensures that your Terraform commits are:

  1. Following the canonical format and style per terraform fmt
  2. Syntactically valid and internally consistent per terraform validate
  3. Passing TFLint rules
  4. Ensuring that good practices are followed such as:
    • merge conflicts are resolved
    • private ssh keys aren't included
    • commits are done to a branch instead of directly to master or main
repos:
  - repo: git://github.com/antonbabenko/pre-commit-terraform
    rev: v1.59.0
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_tflint
        args:
          - '--args=--config=__GIT_WORKING_DIR__/.tflint.hcl'
  - repo: git://github.com/pre-commit/pre-commit-hooks
    rev: v4.0.1
    hooks:
      - id: check-added-large-files
      - id: check-merge-conflict
      - id: check-vcs-permalinks
      - id: check-yaml
      - id: detect-private-key
      - id: end-of-file-fixer
      - id: no-commit-to-branch
      - id: trailing-whitespace

You can take advantage of this configuration by:

  • Installing the pre-commit framework per the instructions on the website.
  • Creating the above configuration in the root directory of your Git repository as .pre-commit-config.yaml
  • Creating a .tflint.hcl in the base directory of the repository
  • Initialize the pre-commit hooks by running pre-commit install

Now whenever you create a commit, the hooks will run against any changed files and report back issues.

Since the pre-commit framework normally only runs against changed files, it’s a good idea to start off by validating all files in the repository by running pre-commit run –all-files

Conclusion

These approaches help make it easier and safer to refactor Terraform codebases, speeding up a team's "Infrastructure as Code" velocity.

This helped my team gain confidence in making changes to our legacy modules and enabled greater reusability. Standardizing on formatting and validation checks also sped up code reviews. We could focus on module logic instead of looking for typos or broken syntax

No comments :