Module 3: The Iac Pipeline Fmt Validate Plan

6. Hands-on: `terraform plan` running inside CI

Description

This is the lesson where ci.yml runs, for the first time, the command that gives this entire module its meaning: terraform plan against Andes Cargo's real infrastructure, inside one of act's ephemeral containers. You're going to install tflocal on the runner (the same LocalStack wrapper you used in terraform-and-iac-guide), run the plan, and read its complete, literal output — including a real finding, verified today, that connects directly to the previous lesson: why this specific plan doesn't need LocalStack to be running to correctly calculate what it would create.

Connection to the module

Lesson 5 left you with the network connection confirmed, but with an honest failure (LocalStack off). This lesson uses the same variable (AWS_ENDPOINT_URL: http://host.docker.internal:4566) for a much more important command — and, against what you might expect after lesson 5, this command does succeed, without LocalStack running. Lesson 7 takes exactly this output and turns it into the review artifact that closes out half of the pipeline's CI.


Analogy: a construction estimate, not the construction itself

When a contractor prepares an estimate to remodel a kitchen, they don't need to have the kitchen in front of them yet to calculate how much material is needed, how many work hours, what gets installed and what gets removed — they need the blueprints and the materials catalog, not the work in progress. terraform plan, when it starts from completely empty state (nothing created yet, as is Andes Cargo's case in this guide), works similarly: it can precisely calculate what it's going to create, based solely on your HCL, without needing to check anything that already exists on the other side. That changes the moment something's already built —then, yes, a remodel's estimate needs to measure the real kitchen first— but for a job starting from zero, the catalog is enough.


Step 1 — Install tflocal and run the plan

Add two more steps to ci.yml, after lesson 5's awslocal step:

      - name: Install tflocal
        run: pip3 install --quiet --break-system-packages terraform-local

      - name: Terraform plan
        run: tflocal plan -input=false -no-color | tee plan-output.txt

Two details about this step, before running it:

  • tflocal, not terraform — the same decision you made in terraform-and-iac-guide starting in its Module 1: tflocal is a wrapper that automatically generates, in a temporary file, the complete provider "aws" { endpoints {...} } } block pointing at LocalStack —without providers.tf, which stays minimal, having to declare it by hand. The AWS_ENDPOINT_URL variable already in the job's env (lesson 5) is exactly what tflocal reads to know the destination is host.docker.internal:4566, not localhost:4566.
  • | tee plan-output.txttee prints the output to the terminal (you see it in act's logs, like any other step) and, at the same time, saves it to a file. Lesson 7 uses exactly that file to publish the plan as review evidence.

A real finding: providers.tf needs one more line than you'd assume

Before running this, there's a detail verified today, running the command for real, worth naming with complete honesty —in the same spirit that Module 2 (lesson 3) discovered act doesn't evaluate branches:. terraform-and-iac-guide taught you that tflocal automatically generates the skip_credentials_validation and skip_metadata_api_check flags inside its override block — true, confirmed. What it does not automatically generate, in the version of tflocal this guide uses, is a third flag: skip_requesting_account_id. Without it, the AWS provider tries —even with test credentials and the other two flags active— to figure out the account ID by contacting IAM/STS before calculating the plan, something it can only achieve if LocalStack is running and reachable.

That's why andes-cargo-infra/'s providers.tf carries this single line, the only deviation from the "minimal providers.tf" terraform-and-iac-guide established:

provider "aws" {
  region                      = "us-east-1"
  skip_requesting_account_id  = true
}

It's a provider-level setting, not a LocalStack-specific trick —it would have exactly the same effect, and be just as valid, if this project pointed at a real AWS account— it tells the provider "you don't need to know the account ID before calculating a plan," something true for a complete create like Andes Cargo's, where no resource needs that data to decide what to create. With this line added, terraform plan can complete without touching the network at all — the exact reason this command, unlike lesson 5's awslocal, doesn't depend on LocalStack running.


Step 2 — Running the plan, with LocalStack still off

docker ps -a --filter name=localstack_main

What to expect (literal): no rows — LocalStack is still off, exactly like in lesson 5.

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

What to expect (literal output, executed to write this lesson — excerpt; the plan's complete output exceeds 400 lines, so a representative cut of the start, one complete resource, and the closing is shown):

[ci/terraform-checks] ⭐ Run Main Install tflocal
[ci/terraform-checks]   | WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
[ci/terraform-checks]   ✅  Success - Main Install tflocal [2.319248s]
[ci/terraform-checks] ⭐ Run Main Terraform plan
[ci/terraform-checks]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/8] user= workdir=
[ci/terraform-checks]   | data.archive_file.lambda_zip: Reading...
[ci/terraform-checks]   | data.archive_file.lambda_zip: Read complete after 0s [id=772c6895d44fc6c470f613203a7df0faeee06e87]
[ci/terraform-checks]   | data.aws_iam_policy_document.require_https: Reading...
[ci/terraform-checks]   | data.aws_iam_policy_document.app_server_permissions: Reading...
[ci/terraform-checks]   | data.aws_iam_policy_document.lambda_permissions: Reading...
[ci/terraform-checks]   | data.aws_iam_policy_document.lambda_trust: Reading...
[ci/terraform-checks]   | data.aws_iam_policy_document.ec2_trust: Reading...
[ci/terraform-checks]   | data.aws_iam_policy_document.lambda_permissions: Read complete after 0s [id=4087165242]
[ci/terraform-checks]   | data.aws_iam_policy_document.ec2_trust: Read complete after 0s [id=2851119427]
[ci/terraform-checks]   | data.aws_iam_policy_document.lambda_trust: Read complete after 0s [id=2690255455]
[ci/terraform-checks]   | data.aws_iam_policy_document.require_https: Read complete after 0s [id=4186015114]
[ci/terraform-checks]   | data.aws_iam_policy_document.app_server_permissions: Read complete after 0s [id=803512137]
[ci/terraform-checks]   |
[ci/terraform-checks]   | Terraform used the selected providers to generate the following execution
[ci/terraform-checks]   | plan. Resource actions are indicated with the following symbols:
[ci/terraform-checks]   |   + create
[ci/terraform-checks]   |
[ci/terraform-checks]   | Terraform will perform the following actions:
[ci/terraform-checks]   |
      [ ... 11 more resources, one by one, each with its own + resource "..." { ... } block ... ]
[ci/terraform-checks]   |   # aws_dynamodb_table.shipments will be created
[ci/terraform-checks]   |   + resource "aws_dynamodb_table" "shipments" {
[ci/terraform-checks]   |       + billing_mode     = "PAY_PER_REQUEST"
[ci/terraform-checks]   |       + hash_key         = "shipmentId"
[ci/terraform-checks]   |       + name             = "Shipments"
[ci/terraform-checks]   |       + region           = "us-east-1"
[ci/terraform-checks]   |       + tags             = {
[ci/terraform-checks]   |           + "Environment" = "dev"
[ci/terraform-checks]   |           + "ManagedBy"   = "terraform"
[ci/terraform-checks]   |           + "Project"     = "andes-cargo"
[ci/terraform-checks]   |         }
[ci/terraform-checks]   |
[ci/terraform-checks]   |       + attribute {
[ci/terraform-checks]   |           + name = "shipmentId"
[ci/terraform-checks]   |           + type = "S"
[ci/terraform-checks]   |         }
[ci/terraform-checks]   |     }
      [ ... the rest of the resources ... ]
[ci/terraform-checks]   | Plan: 12 to add, 0 to change, 0 to destroy.
[ci/terraform-checks]   |
[ci/terraform-checks]   | Changes to Outputs:
[ci/terraform-checks]   |   + process_shipment_manifest_function_name = "process-shipment-manifest"
[ci/terraform-checks]   |   + shipment_docs_bucket_arn                = (known after apply)
[ci/terraform-checks]   |   + shipments_table_name                    = "Shipments"
[ci/terraform-checks]   |
[ci/terraform-checks]   | Note: You didn't use the -out option to save this plan, so Terraform can't
[ci/terraform-checks]   | guarantee to take exactly these actions if you run "terraform apply" now.
[ci/terraform-checks]   ✅  Success - Main Terraform plan [4.348992625s]
[ci/terraform-checks] ⭐ Run Complete job
[ci/terraform-checks]   ✅  Success - Complete job
[ci/terraform-checks] 🏁  Job succeeded

Plan: 12 to add, 0 to change, 0 to destroy. — twelve resources, the complete count of what terraform-and-iac-guide declared in its capstone: the bucket, its policy, its versioning (three resources from the s3-bucket module), the two roles with their inline policies (four resources from the iam-role module, duplicated for LambdaManifestProcessorRole and AppServerRole), the Lambda function, the permission letting S3 invoke it, the bucket notification pointing to the function, the Shipments table, and the inline policy giving the function write permission on that table. Zero resources to change, zero to destroy — exactly what you'd expect from a plan against state that doesn't have anything created yet.


Confirming the finding: LocalStack is still off

docker ps -a --filter name=localstack_main

What to expect (literal): still no rows. The plan above finished in 4.3 seconds, with no visible network retry in the output —contrast that with lesson 5's 11.3 seconds, where awslocal did retry against an unreachable endpoint. That time difference is external confirmation that this plan, with skip_requesting_account_id = true in providers.tf, made no network attempt at all.

An honest warning, so you don't draw the wrong conclusion: this is a property of this specific moment in the project —a complete create, with no resource already existing, with no data source needing to read something real from LocalStack. As soon as a terraform.tfstate with real resources exists (something that only starts happening in Module 5, when apply.yml runs for the first time), a later plan is going to need to read those resources' real state against the provider, and at that point, the connection you verified in lesson 5 is going to stop being optional.


Common mistakes

Assuming terraform plan never needs LocalStack running (conceptual, this lesson's most tempting incorrect generalization). What happens: someone, after seeing this lesson, concludes plan "always" works with no LocalStack active, and is surprised when a later plan —in Module 5, against already-populated state— does fail without a connection. How to spot it: if your reasoning is "the plan never needs the network" instead of "this specific plan, against this specific state, didn't need it." How to fix it: remember the warning above — the property depends on the state (empty now, with real resources after the first apply) and on whether the HCL has any data source reading something live. It's not a general property of terraform plan.

Forgetting skip_requesting_account_id and not understanding why the plan suddenly fails (configuration-based, see this lesson's finding). What happens: someone rebuilds providers.tf from scratch, copying only what terraform-and-iac-guide documented as "minimal" (region only), without the line this lesson adds, and the plan fails with an error about not being able to retrieve the account ID. How to spot it: an error mentioning iam:GetUser, sts:GetCallerIdentity, or iam:ListRoles, with a connection failure toward host.docker.internal. How to fix it: confirm providers.tf has the line skip_requesting_account_id = true — the only real difference, verified in this lesson, between what terraform-and-iac-guide documented as sufficient and what this specific version of tflocal generates automatically.

Interpreting | tee plan-output.txt as something that changes what the step prints (syntax-based). What happens: someone thinks adding tee to a command hides its output from act's logs, or changes it somehow. How to fix it: tee doesn't hide or transform anything — it prints exactly the same thing you'd see without it, and additionally saves it to the given file. The output you see in act's logs is identical with or without tee; the only thing that changes is that plan-output.txt now also exists, inside the job's container, available to the next step.


Exercises

Exercise 1 — Predict the result without skip_requesting_account_id. If you removed the skip_requesting_account_id = true line from providers.tf and ran the same act pull_request -e pr-event.json -j terraform-checks, with LocalStack still off, what do you expect to happen to the Terraform plan step, and in roughly how much time?

See solution

The step would fail, not succeed — the AWS provider would try to contact IAM/STS through host.docker.internal:4566 to get the account ID before calculating the plan, and since LocalStack isn't running, that attempt would fail the same way lesson 5's awslocal did: after several seconds of retrying (not instant), with a message mentioning iam:GetUser, sts:GetCallerIdentity, and iam:ListRoles errors while trying "retrieving account information"/"retrieving caller identity."

Exercise 2 — Explain the construction-estimate analogy to a skeptical colleague. A colleague tells you: "if the plan doesn't need LocalStack running, then lesson 5 was unnecessary." Answer them in three sentences, without exaggerating in either direction.

See solution

A complete answer sounds, roughly, like this: "It's not unnecessary — it's the difference between 'this specific plan didn't need it' and 'it's never going to need it.' As soon as Andes Cargo has real infrastructure applied (Module 5) or a data source reading something live, the plan is going to depend on that same connection we tested in lesson 5. Testing the cable before the refrigerator is loaded with groceries is still the right thing to do, even if the first appliance you plugged in turned out not to need it."

Exercise 3 — Count the resources by module. Without looking at the complete output again, from memory: how many of the plan's 12 resources come from module.shipment_docs_bucket, how many from the two combined IAM roles, and how many are "loose" resources (outside any module)?

See solution

3 resources from module.shipment_docs_bucket (aws_s3_bucket, aws_s3_bucket_policy, aws_s3_bucket_versioning). 4 resources from the two combined IAM roles —two per instance of the iam-role module (aws_iam_role + aws_iam_role_policy), once for LambdaManifestProcessorRole and once for AppServerRole. 5 loose resources, declared directly at the project's root: aws_lambda_function.process_shipment_manifest, aws_lambda_permission.allow_s3_invoke, aws_s3_bucket_notification.manifest_processor_trigger, aws_dynamodb_table.shipments, and aws_iam_role_policy.lambda_write_shipments (the policy connecting the Lambda function to the table). 3 + 4 + 5 = 12, this lesson's Plan:'s exact number.


Summary and next step

In this lesson you ran this guide's first real terraform plan, inside an act job, and confirmed, with literal output, that it correctly calculates Andes Cargo's 12 resources — without LocalStack running. You discovered, by verifying the real behavior instead of just assuming it, that tflocal needs one more line than documented (skip_requesting_account_id = true) for this to work against empty state, and understood why that property is specific to this moment in the project, not a general guarantee.

Before moving on you should be able to: explain why a plan against a complete create might not need a network connection, while a plan against already-existing infrastructure would; locate skip_requesting_account_id in providers.tf and explain what problem it solves; and read a Plan: N to add, N to change, N to destroy. precisely, knowing what each number represents.

Lesson 7 takes exactly the plan-output.txt this step generated and turns it into the review artifact that closes out half of the pipeline's CI: published in the job's summary, and —shown, not executed— commented on the Pull Request itself, the pattern lesson 2 cited from HashiCorp's official tutorial.

Resources

  1. Terraform Docs — Command: plan — official reference for terraform plan.
  2. Terraform Registry — hashicorp/aws provider: skip_requesting_account_id — this lesson's finding's central flag.
  3. terraform-local — PyPI — the tflocal package, installed in this lesson inside the runner.
  4. terraform-and-iac-guide, Module 1, lesson 6 (NIEVA) — the original introduction to tflocal, extended here with the skip_requesting_account_id finding.