OnCallReady

Lesson 14.6 · Terraform in Real Life & the Associate Exam · 30 min read

Code quality: fmt, validate, tflint

In plain words

Before a school essay is handed in, three helpers look at it. The first only fixes the handwriting and spacing, never the words. The second checks the sentences make grammatical sense. The third is the experienced teacher who spots "this word is spelled right but it isn't a real place", or "you wrote a paragraph and never used it".

For Terraform, terraform fmt is the handwriting helper (canonical layout, exits 3 on -check if something is off), terraform validate checks the language against provider schemas offline, and tflint is the experienced teacher: unused variables, missing version pins, unpinned module refs, and with the azurerm plugin, VM sizes Azure would reject. All three run in seconds, before any plan.

The problem: reviewers wasting time on things a machine can check

A teammate opens a pull request. The reviewer spends ten minutes pointing out bad indentation, a variable nobody uses, a typo in a VM size, a module pinned to a moving branch - and misses that the change deletes a database. Every one of those small things could have been caught by a tool, in seconds, before a human ever looked.

This lesson is those tools. They run on every push, need no Azure login, and free the reviewer to think about what the change does.

What you need to know already: exit codes and $? (1.7), set -euo pipefail (6.1), terraform init / validate / plan (12.24), provider version constraints (12.3), module sources with ?ref= (13.38).

Three layers of "is this code any good"

terraform fmt -check     style: exactly one canonical layout             exit 3 if not
terraform validate       language: syntax, references, types, arguments  exit 1 on errors
tflint                   judgement: unused code, missing pins, values    exit 2 on issues
                         the provider would reject at apply time

Each catches things the one before cannot. All three run in seconds and need no cloud credentials, so they go at the very start of every pipeline and in a pre-commit hook (both explained below).

The exit codes matter: a script with set -e, or a pipeline, stops at the first command that exits non-zero. So "exit 3" from fmt is what makes the pipeline go red.

fmt

terraform fmt rewrites .tf files into the one standard layout: two-space indentation, = signs lined up, consistent spacing. It never changes what the code means.

# in the Build mission's repo, with two badly formatted files
terraform fmt -check -recursive
modules/aks/aks.tf
envs/prod/main.tf
echo $?
3
terraform fmt -recursive
modules/aks/aks.tf
envs/prod/main.tf

What happened: the first command only checked, listed the two files that are not formatted, and exited 3. The second command (no -check) fixed them and printed the files it changed.

flagmeans
-checkdo not change anything; list unformatted files and exit 3 if there are any
-recursivealso go into subfolders - without it, modules/ and envs/ are skipped
-diffshow the changes it would make, as a diff

In CI you run terraform fmt -check -recursive (fail if anything is off). On your laptop you run terraform fmt -recursive (just fix it).

validate

$ terraform validate
Success! The configuration is valid.

terraform validate checks the code against the provider schemas - the provider's list of which resources exist, which arguments they take and what type each argument is. So it catches typos in argument names, wrong types, and references to variables or resources that do not exist.

It needs terraform init first, because init downloads the providers and their schemas. In CI you do not want the checks to need access to the state storage, so you use:

terraform init -backend=false -input=false

-backend=false installs providers and modules but does not connect to the backend. -input=false means "never stop and ask a question; fail instead" - right for anything that runs unattended.

What validate does not do: it does not read tfvars (so variable values are unknown), does not call Azure, and does not know whether Standard_D4sv5 is a real VM size - to validate, it is just a string.

tflint

tflint is a separate tool (terraform-linters/tflint on GitHub) with pluggable rulesets - bundles of rules. The terraform ruleset is built in. Cloud-specific rulesets such as azurerm are plugins you install.

$ tflint
3 issue(s) found:

Warning: variable "legacy_sku" is declared but not used (terraform_unused_declarations)

  on variables.tf line 18:
  18: variable "legacy_sku" {

Reference: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/v0.9.1/docs/rules/terraform_unused_declarations.md

Warning: Missing version constraint for provider "random" in `required_providers` (terraform_required_providers)

  on main.tf line 21:
  21: resource "random_string" "kv" {

Reference: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/v0.9.1/docs/rules/terraform_required_providers.md

Warning: Module source "git::https://github.com/acme/tf-modules.git//aks?ref=main" uses a default branch as ref (main) (terraform_module_pinned_source)

  on envs/dev/main.tf line 3:
   3:   source = "git::https://github.com/acme/tf-modules.git//aks?ref=main"

How to read one issue:

Here: a variable nobody uses (delete it), a provider with no version constraint (add one), and a module pinned to main, a branch that moves (pin a tag like v1.1.0 instead, 13.40).

Exit status: 0 no issues, 1 tflint itself failed (e.g. a bad config), 2 issues found. --force always exits 0; --minimum-failure-severity=error fails only on errors, not warnings.

The recommended rules

With no configuration, the terraform ruleset runs its recommended preset (a preset is a named set of rules switched on together):

terraform_unused_declarations      variables, locals, data sources nobody uses
terraform_typed_variables          variables without a type
terraform_required_version         no required_version in the terraform block
terraform_required_providers       a provider used without a version constraint
terraform_module_pinned_source     git module without ?ref=, or ref = main/master
terraform_module_version           registry module without version
terraform_deprecated_interpolation "${var.x}" alone (0.11 style)
terraform_deprecated_index         list.0 instead of list[0]
terraform_deprecated_lookup        lookup() with two arguments
terraform_empty_list_equality      == [] comparisons
terraform_map_duplicate_keys       the same key twice in a map literal
terraform_workspace_remote         terraform.workspace with remote execution (TF 1.0)

terraform_deprecated_interpolation is the one that surprises people: writing "${azurerm_resource_group.jump.name}" (a reference wrapped in a string) is old Terraform 0.11 style. Write the reference bare: azurerm_resource_group.jump.name.

Rules that are off by default but worth turning on: terraform_naming_convention (names in snake_case, like aks_subnet), terraform_documented_variables / terraform_documented_outputs (every variable/output has a description), terraform_standard_module_structure (the usual main.tf / variables.tf / outputs.tf files).

.tflint.hcl: the configuration file

tflint reads .tflint.hcl from the folder you run it in. It is HCL, like Terraform code:

plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

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

rule "terraform_naming_convention" {
  enabled = true
}

rule "terraform_documented_variables" {
  enabled = true
}

A plugin must be installed before it can run. tflint --init reads .tflint.hcl and downloads the plugins it lists:

# in a repo whose .tflint.hcl enables the azurerm plugin, before tflint --init has run
tflint
Failed to initialize plugins; Plugin "azurerm" not found. Did you run "tflint --init"?
tflint --init
Installing "azurerm" plugin...
Installed "azurerm" (source: github.com/terraform-linters/tflint-ruleset-azurerm, version: 0.27.0)

What the azurerm ruleset adds

The ruleset knows the allowed values of many "pick one of a list" arguments - VM sizes, replication types, SKUs (Azure's word for a product tier or size) - and flags values Azure would reject:

Error: "Standard_D4sv5" is an invalid value as size (azurerm_linux_virtual_machine_invalid_size)

  on main.tf line 30:
  30:   size                = "Standard_D4sv5"

(The real name is Standard_D4s_v5 - one missing underscore.)

terraform validate passes that configuration: size is a valid argument and the value is a string. terraform plan passes it too. The failure would come ten minutes into apply, from Azure itself - possibly after other resources were already created. tflint moves it to the first second of the pipeline. (The lab's ruleset knows a subset of the real value lists - simulator.)

Useful flags

tflint --recursive                 every directory with .tf files below here
tflint --format=compact            file:line:col: Severity - message (rule) - for CI logs
tflint --format=json               for tooling
tflint --chdir=envs/prod           as if started there
tflint --minimum-failure-severity=warning

Wiring it together

The whole set, as a script a pipeline (or you) runs:

#!/usr/bin/env bash
set -euo pipefail
terraform fmt -check -recursive
terraform init -backend=false -input=false
terraform validate
tflint --init
tflint --recursive --format=compact

The order is cheapest first: fmt needs nothing, validate needs providers, tflint needs its plugins. set -euo pipefail (6.1) stops at the first failure, so the output always ends at the first real problem.

Every mistake caught here is a mistake that never reaches a plan reviewer - who should be spending attention on what the change does, not on indentation.

Worked example: one failing run, three fixes

# the quality mission's ci/check.sh, on a branch with three problems
./ci/check.sh
== fmt
modules/storage/main.tf
main.tf
echo $?
3
terraform fmt -recursive
modules/storage/main.tf
main.tf
./ci/check.sh
== fmt
== validate
╷
│ Error: Reference to undeclared input variable
│
│   on modules/storage/main.tf line 14, in resource "azurerm_storage_account" "this":
│   14:   resource_group_name      = var.resource_group
│
│ An input variable with the name "resource_group" has not been declared. This
│ variable can be declared with a variable "resource_group" {} block.
╵

Step by step:

  1. First run: fmt listed two files and the script stopped with exit 3.
  2. terraform fmt -recursive fixed them.
  3. Second run: fmt passed (== fmt with nothing under it), validate failed. The module uses var.resource_group but never declares a variable "resource_group" block. That is a real bug, not style.

After declaring the variable in the module (and passing it from the root), the third run gets to tflint:

== tflint
2 issue(s) found:

Warning: terraform "required_version" attribute is required (terraform_required_version)
Warning: Missing version constraint for provider "azurerm" in `required_providers` (terraform_required_providers)

The module does not say which Terraform and which provider version it needs. A module states a minimum (required_version = ">= 1.9", azurerm ">= 4.0"); lesson 14.23 explains why modules use minimums and roots use tight pins.

pre-commit: the same checks before every commit

A git hook is a script git runs automatically at a certain moment - a pre-commit hook runs just before each git commit, and can stop the commit if a check fails. pre-commit is a popular tool that manages such hooks from a config file. The widely used Terraform hook collection is antonbabenko/pre-commit-terraform:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.96.1              # pin a release tag
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_tflint
      - id: terraform_checkov

(terraform_checkov runs the security scanner from the next lesson.) Setting it up:

pip install pre-commit        # or brew install pre-commit
pre-commit install            # adds the hook to the repository
pre-commit run --all-files    # run everything once

pip install / brew install install the tool itself (Python's and macOS's package managers). pre-commit install adds the hook to this repository's .git/ folder. pre-commit run --all-files runs every check on every file once.

Hooks only check the files you changed, so they stay fast. CI still runs the full set, because a hook can be skipped (git commit --no-verify) or simply not installed on someone's laptop.

What each tool cannot see

fmt        meaning of anything
validate   variable values (tfvars are not read), provider-side rules, the cloud
tflint     real resources and state; only what its rules know (the azurerm ruleset
           knows valid VM sizes, not whether your subscription has quota for one)
plan       everything above - but only by talking to the cloud, and slowly

("Quota" is Azure's limit on how much of something your account may create.)

The cheap tools do not replace the plan; they make sure the plan reviewer only sees changes worth thinking about.

What you can now do:

Why it helps

These are the first steps of every pipeline you will build, and the reason reviewers can focus on what a change does. Situations: a PR fails CI with exit code 3 and you know it is only formatting (terraform fmt -recursive); validate passes but the apply fails ten minutes in on Standard_D4sv5, which tflint's azurerm ruleset would have caught in the first second; tflint warns that a module uses ?ref=main, which is the moving-module incident waiting to happen. You will also set up pre-commit hooks so this never reaches CI. Exit codes and what validate does not check are exam and interview material.

Commands in this lesson

terraform tflint

FAQ

Why does validate pass when the apply fails on an invalid VM size?

validate checks the configuration against provider schemas: that size is an argument and a string. It does not know which strings Azure accepts, does not read tfvars, and does not call Azure. The azurerm tflint ruleset knows valid values for many enum-like arguments and flags Standard_D4sv5 statically. Quota, name conflicts and permissions still only show at plan or apply.

Does validate need init and credentials?

It needs init, because it checks against provider schemas that init downloads. It does not need backend or cloud credentials: terraform init -backend=false -input=false in CI gives the schemas without touching state. That is why validate can run on every PR, even from forks without secrets.

What does tflint check that Terraform does not?

Judgement rules: declared but unused variables, locals and data sources; variables without types; missing required_version; providers without version constraints; git modules without a pinned ref or on main; registry modules without version; deprecated syntax. With the azurerm plugin, invalid values for VM sizes, SKUs, replication types and more. Plugins must be installed with tflint --init.

What are the exit codes, and why do they matter?

fmt -check exits 3 when files need formatting, validate exits 1 on errors, tflint exits 2 when it finds issues and 1 when tflint itself fails. Pipelines and scripts with set -euo pipefail stop at the first non-zero exit, so the output ends at the first real problem. tflint --minimum-failure-severity=error fails only on errors.

Are pre-commit hooks enough, or do I still need CI checks?

You need both. Hooks such as antonbabenko/pre-commit-terraform run fmt, validate, tflint and checkov on changed files before a commit, so problems are caught earliest. But hooks can be skipped with git commit --no-verify and not everyone installs them, so CI runs the full set as the gate that cannot be bypassed.

In an interview Junior

What is the difference between terraform fmt, terraform validate and tflint?

Three layers, cheapest first, none needs a cloud login:

Chain them in a script with set -euo pipefail, run it in CI on every PR and in a pre-commit hook.

Also asked: What can these static checks not catch? · What is a pre-commit hook, and why do you still need CI? · How would you introduce tflint on a repo with hundreds of warnings?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.