Module 3: The Iac Pipeline Fmt Validate Plan

4. Hands-on: `fmt` and `validate` as steps that can fail the job

Description

This is this module's first lesson where you write ci.yml for real, inside andes-cargo-infra/, and run it with act. You're going to build the first two steps —terraform fmt -check and terraform validate— confirm they pass on the HCL inherited from terraform-and-iac-guide, and then you're going to break that HCL on purpose, twice: first a formatting error, then a syntax/semantic error — to watch, with your own eyes, act's job fail red, read the exact message each one produces, fix it, and confirm it passes green again. Every "What to expect" block in this lesson is literal output, executed today, to write this lesson.

Connection to the module

Lesson 3 left you with Terraform installable on any run (hashicorp/setup-terraform@v3, pinned to 1.15.8). This lesson uses exactly that piece to take ci.yml's first real step: two cheap, fast checks that touch no AWS/LocalStack service —that's why they go first, per what you already reasoned in lesson 1's Exercise 1. Lesson 5 keeps building on this same file, adding the connection to LocalStack.


Starting point: andes-cargo-infra/ already has the real HCL

Before writing a single line of YAML, confirm something important: your copy of andes-cargo-infra/ —the one you prepared in Module 1 and extended in Module 2— already carries, inherited unchanged from terraform-and-iac-guide, that guide's complete capstone HCL: versions.tf (required_version = ">= 1.15.0", AWS provider ~> 6.0), providers.tf (minimal, ready for tflocal), variables.tf/locals.tf/outputs.tf, and the four resource files —s3.tf (bucket andes-cargo-shipment-docs), iam.tf (roles LambdaManifestProcessorRole and AppServerRole), lambda.tf (function process-shipment-manifest), dynamodb.tf (table Shipments)— plus the reusable modules in modules/s3-bucket/ and modules/iam-role/. This lesson doesn't rewrite a single line of that business HCL; the entire job is the pipeline that runs it.

cd andes-cargo-infra
git log --oneline -- s3.tf

What to expect (confirms the business HCL is already in your history, inherited, not something this lesson introduces):

ce6efa5 Bootstrap CI/CD layer: initialize git repository, .actrc, and .github/workflows/

Step 1 — ci.yml's skeleton

.github/workflows/ci.yml:

name: ci

on:
  pull_request:
    branches: [main]

jobs:
  terraform-checks:
    runs-on: ubuntu-latest
    env:
      AWS_ACCESS_KEY_ID: test
      AWS_SECRET_ACCESS_KEY: test
      AWS_DEFAULT_REGION: us-east-1
      AWS_ENDPOINT_URL: http://host.docker.internal:4566
    steps:
      - name: Check out andes-cargo-infra
        uses: actions/checkout@v4

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.15.8"

      - name: Terraform format check
        run: terraform fmt -check -recursive

      - name: Terraform init
        run: terraform init -input=false

      - name: Terraform validate
        run: terraform validate

Three decisions worth naming before running it:

  • on: pull_request: branches: [main] — the exact trigger lesson 2 justified: this workflow calculates and shows, never applies, and only makes sense on a proposed change against main. Remember Module 2's finding (lesson 3): act doesn't evaluate branches: — you're going to be able to trigger this job with any pull_request event, with the branch filter not blocking the simulation; the filter remains correct for real GitHub.
  • Job-level env, with AWS_ENDPOINT_URL: http://host.docker.internal:4566 — previewed from lesson 5. This variable doesn't do anything to fmt/validate yet (neither talks to AWS/LocalStack), but you leave it declared from now because the rest of the job —awslocal, and in lesson 6, tflocal— does need it, and this way the complete env block is visible from the start.
  • terraform fmt -check -recursive — the -recursive flag is necessary because this project has HCL inside modules/, not just at the root; without it, fmt -check would only look at the current directory's files.

Step 2 — Confirming the baseline: everything green

act pull_request -e .github/act-events/pr-event.json -j terraform-checks

What to expect (literal output, executed to write this lesson):

[ci/terraform-checks] ⭐ Run Set up job
[ci/terraform-checks] 🚀  Start image=catthehacker/ubuntu:act-la***
[ci/terraform-checks]   ✅  Success - Set up job
[ci/terraform-checks]   ☁  git clone 'https://github.com/hashicorp/setup-terraform' # ref=v3
[ci/terraform-checks] ⭐ Run Main Check out andes-cargo-infra
[ci/terraform-checks]   🐳  docker cp src=/path/to/andes-cargo-infra/. dst=/path/to/andes-cargo-infra
[ci/terraform-checks]   ✅  Success - Main Check out andes-cargo-infra [26.727958ms]
[ci/terraform-checks] ⭐ Run Main Set up Terraform
[ci/terraform-checks]   🐳  docker cp src=/Users/.../hashicorp-setup-terraform@v3/ dst=/var/run/act/actions/hashicorp-setup-terraform@v3/
[ci/terraform-checks]   | [command]/usr/bin/unzip -o -q /tmp/fda1f22d-b719-47ed-9f10-b233363c2e89
[ci/terraform-checks]   ✅  Success - Main Set up Terraform [2.362979916s]
[ci/terraform-checks] ⭐ Run Main Terraform format check
[ci/terraform-checks]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/2] user= workdir=
[ci/terraform-checks]   ✅  Success - Main Terraform format check [141.175208ms]
[ci/terraform-checks] ⭐ Run Main Terraform init
[ci/terraform-checks]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/3] user= workdir=
[ci/terraform-checks]   | Initializing the backend...
[ci/terraform-checks]   |
[ci/terraform-checks]   | Initializing modules...
[ci/terraform-checks]   | - lambda_manifest_processor_role in modules/iam-role
[ci/terraform-checks]   | - shipment_docs_bucket in modules/s3-bucket
[ci/terraform-checks]   | - app_server_role in modules/iam-role
[ci/terraform-checks]   |
[ci/terraform-checks]   | Initializing provider plugins...
[ci/terraform-checks]   | - Finding hashicorp/aws versions matching "~> 6.0"...
[ci/terraform-checks]   | - Finding hashicorp/archive versions matching "~> 2.0"...
[ci/terraform-checks]   | - Installing hashicorp/aws v6.60.0...
[ci/terraform-checks]   | - Installed hashicorp/aws v6.60.0 (signed by HashiCorp)
[ci/terraform-checks]   | - Installing hashicorp/archive v2.8.0...
[ci/terraform-checks]   | - Installed hashicorp/archive v2.8.0 (signed by HashiCorp)
[ci/terraform-checks]   |
[ci/terraform-checks]   | Terraform has been successfully initialized!
[ci/terraform-checks]   ✅  Success - Main Terraform init [12.343412291s]
[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/4] user= workdir=
[ci/terraform-checks]   | Success! The configuration is valid.
[ci/terraform-checks]   ✅  Success - Main Terraform validate [1.924798541s]
[ci/terraform-checks] ⭐ Run Complete job
[ci/terraform-checks]   ✅  Success - Complete job
[ci/terraform-checks] 🏁  Job succeeded

Notice two things that confirm ideas from previous lessons, with your own eyes: - Installing hashicorp/aws v6.60.0... is the runner downloading the provider again, inside this ephemeral container, even though your laptop already has that same provider installed from terraform-and-iac-guide — exactly this module's lesson 3, confirmed in practice. And git clone 'https://github.com/hashicorp/setup-terraform' is act resolving the @v3 tag the first time it's used on this machine —you're going to see it just once, with act caching the Action locally for subsequent runs.


Step 3 — Breaking the format on purpose

Open iam.tf and misalign, by hand, the = signs in the first module block:

module "lambda_manifest_processor_role" {
  source = "./modules/iam-role"

  role_name                = "LambdaManifestProcessorRole"
  trust_policy_json        = data.aws_iam_policy_document.lambda_trust.json
  permissions_policy_json  = data.aws_iam_policy_document.lambda_permissions.json
  tags                     = local.common_tags
}

(The correct version, which you had before, aligns all four = signs to the same column, calculated from the block's longest key — permissions_policy_json. Notice the HCL remains syntactically valid with this misalignment: Terraform would understand it just the same. It's purely a matter of style, and it's exactly what fmt -check exists to catch.)

act pull_request -e .github/act-events/pr-event.json -j terraform-checks

What to expect (literal output, executed to write this lesson — the job fails red):

[ci/terraform-checks] ⭐ Run Main Terraform format check
[ci/terraform-checks]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/2] user= workdir=
[ci/terraform-checks]   | iam.tf
[ci/terraform-checks]   ❗  ::error::Terraform exited with code 3.
[ci/terraform-checks]   ❌  Failure - Main Terraform format check [137.46625ms]
[ci/terraform-checks] exitcode '1': failure
[ci/terraform-checks] ⭐ Run Complete job
[ci/terraform-checks]   ✅  Success - Complete job
[ci/terraform-checks] 🏁  Job failed
Error: Job 'terraform-checks' failed

Read this carefully: terraform fmt -check doesn't print a complete diff by default —only the name of the file that doesn't meet the expected format (iam.tf)— and exits with code 3 (the specific code Terraform uses for "there are formatting differences," different from the generic 1 of a general error). act translates that non-zero exit code into a failed job, and —this is the most important part— no subsequent step ran: not terraform init, not terraform validate. The job stops at the first step that fails, exactly the behavior that makes putting fmt -check first valuable: cheap, fast, and if it fails, you don't spend time on more expensive steps.


Step 4 — Fixing the format

terraform fmt iam.tf

This rewrites the file, realigning the = signs automatically —the same command you ran, without -check, in terraform-and-iac-guide every time Terraform warned you about a badly formatted file. Run the job again:

act pull_request -e .github/act-events/pr-event.json -j terraform-checks

What to expect (literal output, executed to write this lesson — fmt passes, and now it reaches validate):

[ci/terraform-checks] ⭐ Run Main Terraform format check
[ci/terraform-checks]   ✅  Success - Main Terraform format check [138.0015ms]
[ci/terraform-checks] ⭐ Run Main Terraform init
[ci/terraform-checks]   ✅  Success - Main Terraform init [10.937006333s]
[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks]   | Success! The configuration is valid.
[ci/terraform-checks]   ✅  Success - Main Terraform validate [1.800934209s]
[ci/terraform-checks] 🏁  Job succeeded

Step 5 — Breaking validation on purpose

fmt only looks at style — it would never detect, for example, a reference to something that doesn't exist. That's what validate is for. Break something different, a real typo in a local variable name, in iam.tf's second module block:

module "app_server_role" {
  source = "./modules/iam-role"

  role_name               = "AppServerRole"
  trust_policy_json       = data.aws_iam_policy_document.ec2_trust.json
  permissions_policy_json = data.aws_iam_policy_document.app_server_permissions.json
  tags                    = local.common_tagz
}

(Notice: local.common_tagz, with a "z" instead of an "s" — a one-letter typo, the kind you make typing fast, that fmt would never catch, because the HCL remains perfectly well formatted.)

act pull_request -e .github/act-events/pr-event.json -j terraform-checks

What to expect (literal output, executed to write this lesson — fmt passes, init passes, validate fails with a message that tells you exactly what's wrong):

[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/4] user= workdir=
[ci/terraform-checks]   | ╷
[ci/terraform-checks]   | │ Error: Reference to undeclared local value
[ci/terraform-checks]   | │
[ci/terraform-checks]   | │   on iam.tf line 74, in module "app_server_role":
[ci/terraform-checks]   | │   74:   tags                    = local.common_tagz
[ci/terraform-checks]   | │
[ci/terraform-checks]   | │ A local value with the name "common_tagz" has not been declared. Did you
[ci/terraform-checks]   | │ mean "common_tags"?
[ci/terraform-checks]   | ╵
[ci/terraform-checks]   ❗  ::error::Terraform exited with code 1.
[ci/terraform-checks]   ❌  Failure - Main Terraform validate [1.822465458s]
[ci/terraform-checks] exitcode '1': failure
[ci/terraform-checks] 🏁  Job failed
Error: Job 'terraform-checks' failed

This is an error message with a quality worth calling out: Terraform doesn't just say "something's wrong" — it gives you the exact file and line (iam.tf line 74), reproduces the complete line, and even suggests the fix ("Did you mean "common_tags"?"), because the name you wrote is very close to one that does exist. Notice too that fmt -check and terraform init both ran green before validate failed — the earlier formatting problem no longer exists (you fixed it in Step 4), and the new problem is a completely different type: not style, a reference to something nonexistent.


Step 6 — Fixing and revalidating

sed -i '' 's/local.common_tagz/local.common_tags/' iam.tf

(Or fix it by hand in your editor — the "z" back to "s".)

act pull_request -e .github/act-events/pr-event.json -j terraform-checks

What to expect (literal output, executed to write this lesson — back to green, end to end):

[ci/terraform-checks] ⭐ Run Main Terraform validate
[ci/terraform-checks]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/4] user= workdir=
[ci/terraform-checks]   | Success! The configuration is valid.
[ci/terraform-checks]   ✅  Success - Main Terraform validate [1.7687455s]
[ci/terraform-checks] ⭐ Run Complete job
[ci/terraform-checks]   ✅  Success - Complete job
[ci/terraform-checks] 🏁  Job succeeded

Commit ci.yml —this module's first new artifact:

git add .github/workflows/ci.yml
git commit -m "Add ci.yml: terraform fmt and validate as CI steps"

What to expect (representative for the hash, literal for the message):

[main ba47438] Add ci.yml: terraform fmt and validate as CI steps

Going deeper: why two distinct checks, not just one

fmt -check and validate test things that don't overlap, and this lesson demonstrated it in practice: Step 3's error (misaligned =) is valid for validate —Terraform understands it perfectly— but invalid for fmt -check. Step 5's error (common_tagz) is exactly the opposite: perfectly formatted, but semantically broken. Neither command, on its own, catches both types of problem — you need both, in that order (cheap first), to cover the two categories of "this is wrong" an HCL change can introduce before a terraform plan even gets calculated.


Common mistakes

Error: Could not find any stages to run when requesting the wrong event (recap from Module 2, relevant again here). What happens: someone runs act push instead of act pull_request on ci.yml, which only listens for pull_request. How to spot it: the exact message Could not find any stages to run, already seen in Module 1/lesson 7 and Module 2/lesson 3. How to fix it: check act -lci.yml's Events column says pull_request, not push.

docker: Error response from daemon when starting act without Docker running. What happens: Docker Desktop (or the Docker daemon) isn't active when you run any act command. How to spot it: the message mentions being unable to connect to the Docker daemon, not a Terraform or YAML error. How to fix it: confirm with docker ps that the daemon responds before running act — the same check from Module 1, lesson 6.

Confusing fmt -check's exit code 3 with a generic error (diagnosis-based). What happens: someone sees Terraform exited with code 3 and looks that code up as if it were an unknown error or a tool bug. How to spot it: the specific code 3, together with a filename printed right before it (with no "Error:" message at all). How to fix it: code 3 is terraform fmt -check's specific signal for "there are unformatted files" — it's not a tool failure, it's exactly the result -check is designed to produce when it finds something. Run terraform fmt -diff <file> locally to see exactly what would change, before applying terraform fmt without -check.


Exercises

Exercise 1 — Predict which step fails first. If iam.tf had, at the same time, Step 3's formatting error and Step 5's validation error, which ci.yml step would fail, and which ones wouldn't get to run?

See solution

Terraform format check would fail first, because it's the first step in the file's order that touches that error. Terraform init and Terraform validate wouldn't run at all —not to confirm they're fine, not to reveal the second error— because act (like real GitHub Actions) stops the job at the first step that fails, unless continue-on-error: true is explicitly declared on that step (something this ci.yml doesn't do for fmt/validate, on purpose: both are blocking). Only after fixing the formatting error and running again would validate get the chance to reveal the second error.

Exercise 2 — Explain why validate's message is more useful than fmt -check's. Compare the two error messages you saw in this lesson. Why does validate give you more information than fmt -check about exactly what's wrong?

See solution

fmt -check only needs to tell you which file doesn't meet the expected format —the complete file can be fixed automatically with terraform fmt, with no need to point at a specific line. validate, on the other hand, is evaluating the HCL's semantics —whether a reference exists, whether a type is correct— a kind of error that can't be "auto-fixed" without knowing the intent of whoever wrote the code; that's why it needs to point at the file, the exact line, reproduce the code, and even suggest a probable fix, because only a human (or whoever wrote the change) can confirm what the real intent was.

Exercise 3 — Decide whether a third type of error would exist. Terraform also has a terraform plan command that can fail for reasons neither fmt nor validate detect (for example, a resource that already exists with the same name in the real provider). Why don't fmt/validate catch that kind of problem, based on what you learned about what each one evaluates?

See solution

Neither fmt nor validate talks to any real provider (AWS/LocalStack) — both work exclusively on the HCL's text, with no network call. A conflict against the infrastructure's real state (a resource that already exists, an account limit reached, insufficient permission) can only be discovered at the moment Terraform does talk to the provider — exactly what terraform plan does, this module's lesson 6's topic. It's, literally, the third category of check that completes the trio: style (fmt), internal consistency (validate), and consistency against the real world (plan).


Summary and next step

In this lesson you built ci.yml's first two real steps —terraform fmt -check and terraform validate— and watched them fail red, twice, for different reasons: a formatting error (code 3, with no more detail than the filename) and a reference error (complete message, with file, line, and a suggested fix). You confirmed an act job stops at the first step that fails, and that fixing each error —terraform fmt for the first, editing the HCL by hand for the second— brings the job back to green, end to end.

Before moving on you should be able to: explain what kind of error fmt -check catches versus validate, with an example of each; predict which steps of a job run and which don't when one fails in the middle; and read terraform fmt -check's exit code 3 without confusing it with a generic error.

Lesson 5 keeps building on this same ci.yml, adding the networking piece connecting the job's container with the LocalStack running on your host — the direct prerequisite for the real terraform plan arriving in lesson 6.

Resources

  1. Terraform Docs — Command: fmt — official reference for terraform fmt, including the -check flag and its exit codes.
  2. Terraform Docs — Command: validate — official reference for terraform validate.
  3. nektosact.com — User Guide — documentation for act pull_request -e, used in every run in this lesson.
  4. terraform-and-iac-guide (NIEVA) — Andes Cargo's HCL, which this lesson runs for the first time inside a pipeline, without rewriting a single line of business logic.