Module 6: Rollback And Safety Nets
7. Hands-on: a minimal, real, executed guardrail
Description
This is the lesson where you genuinely build the mechanism lessons 5 and 6 anticipated: a ci.yml step that reads the JSON plan (terraform show -json) and fails the job if that plan tries to destroy the Shipments table. It's not conftest, it's not a declarative policy — it's, on purpose, a hand-crafted grep, the minimal real pattern the industry would use as a first step before building something more serious. You're going to test it twice: once where it passes (a normal plan, with no destruction at all), and once where it genuinely fails, in red, with the complete job stopped before ever reaching apply.
Connection to the module
Lesson 6 left you with the right question and the promise that this lesson would answer it with real code. This lesson keeps that promise, and along the way solves an honest problem lessons 3 and 6 already flagged: testing a destruction guardrail needs a plan that proposes destroying something that already exists — and this project's state, on this machine, without a real apply completed, never had anything to destroy. Lesson 8 —this module's project— integrates this guardrail into the complete pipeline and tests it two more times, on Andes Cargo's narrative thread.
Analogy: the metal detector that only checks one pocket
A complete airport security system scans all luggage, against an extensive, up-to-date list of prohibited objects, with machines capable of distinguishing complex shapes. This lesson's guardrail is much more modest than that — it's like a guard with a single, very specific instruction: "check whether someone is carrying, in this exact pocket, this exact object." It doesn't replace the complete system —it doesn't check any other pocket, doesn't recognize any other object—, but within its specific instruction, it genuinely works, every single time, without getting tired or distracted. It's exactly the kind of control worth having while the complete system gets built, and exactly the kind of control lesson 6 already warned shouldn't be confused with the complete system.
Step 1 — The guardrail: a grep over the JSON plan
You already know terraform show -json from Module 5 (lesson 3), where you used it to inspect artifacts. Run on a saved plan file (tfplan, the same one ci.yml produces with -out=tfplan since Module 5), it produces a single JSON document —one single line, with no internal line breaks, verified by running the command for real— with, among other fields, a resource_changes array, where each entry describes a resource and the action the plan proposes for it:
{"address":"aws_dynamodb_table.shipments","mode":"managed","type":"aws_dynamodb_table","name":"shipments","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],...
Notice the "actions" field: an array of strings, typically ["create"], ["update"], ["delete"], or ["delete","create"] when Terraform needs to replace an entire resource (destroy it and create it again, for example when an attribute changes that doesn't support an in-place update). It's exactly the field a destruction guardrail needs to inspect: if "actions" contains "delete" for the aws_dynamodb_table.shipments resource, the plan is going to destroy the table.
The complete guardrail, added to ci.yml right after the Terraform plan step and before Publish the plan to the job summary —on purpose, before the plan gets published or uploaded as an artifact, so a blocked plan never reaches apply.yml—:
- name: Guardrail — block any plan that destroys the Shipments table
run: |
tflocal show -json tfplan > plan.json
MATCH=$(grep -oE '"address":"aws_dynamodb_table\.shipments".{0,200}"actions":\[[^]]*\]' plan.json || true)
if echo "$MATCH" | grep -q '"delete"'; then
echo "::error::Guardrail failed: this plan destroys aws_dynamodb_table.shipments (Shipments). Blocking before apply."
echo "$MATCH"
exit 1
fi
echo "Guardrail passed: no destroy action found for aws_dynamodb_table.shipments."
Read it line by line, because each one has a concrete reason:
tflocal show -json tfplan > plan.json— converts the binarytfplanfile (the same one-out=tfplanproduced, since Module 5) to the readable JSON this step needs to inspect.grep -oE '"address":"aws_dynamodb_table\.shipments".{0,200}"actions":\[[^]]*\]'— searches, inside the single-line JSON, for the fragment that starts at the resource's exact address (aws_dynamodb_table.shipments) and continues up to 200 characters afterward, capturing the"actions":[...]array that follows. The{0,200}range isn't arbitrary: it's, verified by running the command for real against this project's actualplan, more than enough to cover themode,type,name, andprovider_namefields Terraform always places betweenaddressandchange.actionsin this order.echo "$MATCH" | grep -q '"delete"'— of everything the firstgrepcaptured, checks whether the text"delete"shows up anywhere. If the resource is being created (["create"]), it doesn't show up; if it's being destroyed (["delete"], or["delete","create"]in a replacement), it does.exit 1— the exit code that turns this step into a job failure, stoppingci.ymlbefore reachingPublish the plan.../Upload the plan...— a blockedplannever becomes an artifactapply.ymlcan download.
Why this is, on purpose, artisanal — and where it breaks
It's worth saying with the same honesty lesson 6 already flagged: this grep works because the JSON this Terraform version produces places "actions" at a predictable distance from "address", inside the same object. If a future Terraform version reordered resource_changes's fields, or if the same text pattern happened to appear elsewhere in the document by coincidence, this grep could fail silently —producing a false negative (letting a real delete through) or a false positive (blocking an innocent plan)—. It's exactly the fragility lesson 6 already named as the underlying reason conftest/Rego exists: a real policy walks the JSON as a data structure, with direct access to the resource_changes[].change.actions field, without depending on where each character falls within a line of text. This grep doesn't claim to be that solution — it's the first rung, honest about its own limit, exactly what cloud-security-and-guardrails-guide (that guide's Module 4) later replaces with a real Rego policy.
Step 2 — Testing the passing case: a normal plan
Run ci.yml, with the guardrail already added, exactly like any other Pull Request in this guide:
export ARTIFACT_ADDR=$(ipconfig getifaddr en0) # Linux: hostname -I | awk '{print $1}'
rm -rf .artifacts && mkdir -p .artifacts
act pull_request -e .github/act-events/pr-event.json -j terraform-checks \
--artifact-server-path ./.artifacts \
--artifact-server-addr "$ARTIFACT_ADDR"
What to expect (literal output, executed to write this lesson — excerpt focused on the guardrail; the rest of the job is identical to what you already know from Module 3):
[ci/terraform-checks] ⭐ Run Main Terraform plan
[ci/terraform-checks] | Plan: 12 to add, 0 to change, 0 to destroy.
[ci/terraform-checks] ✅ Success - Main Terraform plan [4.452198s]
[ci/terraform-checks] ⭐ Run Main Guardrail — block any plan that destroys the Shipments table
[ci/terraform-checks] | Guardrail passed: no destroy action found for aws_dynamodb_table.shipments.
[ci/terraform-checks] ✅ Success - Main Guardrail — block any plan that destroys the Shipments table [2.086478584s]
[ci/terraform-checks] ⭐ Run Main Publish the plan to the job summary
[ci/terraform-checks] ✅ Success - Main Publish the plan to the job summary [74.862333ms]
[ci/terraform-checks] ⭐ Run Main Upload the plan for apply.yml to use later
[ci/terraform-checks] ✅ Success - Main Upload the plan for apply.yml to use later [743.220208ms]
[ci/terraform-checks] 🏁 Job succeeded
Guardrail passed — the grep genuinely ran, against a real 12-creation plan's JSON, and found no delete action on Shipments, exactly as expected: this plan only creates resources, on an empty state. The job continued normally to the end, uploading the artifact like any other run in this guide.
The honest problem: testing the failing case needs something that doesn't exist yet
Here's where it's worth stopping, with the same honesty discipline as the rest of this guide. For the guardrail to have something real to block, you need a plan whose "actions" for Shipments contains "delete" — and "delete" only shows up when Terraform's state believes the resource already exists and the HCL no longer describes it. As Module 3 (lesson 6) and Module 5 (lessons 6 and 7) confirmed, this project's state, on this machine, without a valid LOCALSTACK_AUTH_TOKEN, never had a real apply completed — it is, and always has been, completely empty. Removing the table from the HCL on an empty state doesn't produce a destruction: it simply produces "one less thing to create" — zero resources to destroy, because nothing was ever applied.
This isn't a limitation specific to this guardrail — it's the same structural limitation you already saw with drift.yml in Module 5 (lesson 7): without real infrastructure applied, there's no honest way to generate a plan with a genuine delete action against this LocalStack.
The verified fix is a legitimate, well-scoped technique: seeding the local state with a test file (fixture) that declares, with no real apply needed, that the Shipments table already exists. It's the same principle behind testing any security control against a test case instead of depending on live infrastructure —the same reason, in spirit, why a real policy system (conftest, lesson 6) is usually tested against sample plans (fixtures) before trusting it detects what it should—.
Step 3 — Building the state fixture
Terraform accepts writing its state directly from a file, with terraform state push — a command that doesn't call any cloud provider, it just replaces the local state's content. Build a file that declares the Shipments table as already applied, with the exact attributes the hashicorp/aws provider's schema (version 6.60.0, the same one you already installed since Module 3) expects:
.github/guardrail-fixtures/shipments-already-applied.state.json:
{
"version": 4,
"terraform_version": "1.15.8",
"serial": 1,
"lineage": "b6e3f9a2-4c1d-4e8a-9f2b-7d5c8a1e3f60",
"outputs": {},
"resources": [
{
"mode": "managed",
"type": "aws_dynamodb_table",
"name": "shipments",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 1,
"attributes": {
"arn": "arn:aws:dynamodb:us-east-1:000000000000:table/Shipments",
"attribute": [{"name": "shipmentId", "type": "S"}],
"billing_mode": "PAY_PER_REQUEST",
"deletion_protection_enabled": false,
"global_secondary_index": [],
"hash_key": "shipmentId",
"id": "Shipments",
"local_secondary_index": [],
"name": "Shipments",
"point_in_time_recovery": [],
"range_key": null,
"read_capacity": 0,
"region": "us-east-1",
"replica": [],
"restore_backup_arn": null,
"restore_date_time": null,
"restore_source_name": null,
"restore_source_table_arn": null,
"restore_to_latest_time": null,
"server_side_encryption": [],
"stream_arn": "",
"stream_enabled": false,
"stream_label": "",
"stream_view_type": "",
"table_class": "STANDARD",
"tags": {"Environment": "dev", "ManagedBy": "terraform", "Project": "andes-cargo"},
"tags_all": {"Environment": "dev", "ManagedBy": "terraform", "Project": "andes-cargo"},
"timeouts": null,
"ttl": [],
"write_capacity": 0
},
"sensitive_attributes": [],
"private": ""
}
]
}
]
}
Note the filename: it does not end in .tfstate or .tfstate.* — it's shipments-already-applied.state.json, on purpose. This project's .gitignore, inherited from terraform-and-iac-guide, has the line *.tfstate, which by design excludes any real state from Git's history. This file is not the project's real state — it's a test case (fixture), committed as part of the repository, exactly how you'd commit any test data for an automated test. If you named it something matching *.tfstate, Git would ignore it, and —as you're going to confirm in Step 5— act, running in local mode, wouldn't copy it into the job's container either, because its local checkout respects .gitignore.
A real finding: act's local checkout reflects your working tree, not just your commits
Before continuing, it's worth naming something verified today, running this guide, in the same spirit as every earlier finding in this guide family. When act runs locally, with no real remote GitHub repository configured, its actions/checkout@v4 step does not do a clean clone from a remote — it copies your current working directory's content as-is, including uncommitted changes to files already tracked by Git. Confirmed with a direct test: a file modified on disk, never committed, showed up exactly with its modified content inside the job's container.
But there's an exact limit to this, also verified: any file matching a .gitignore pattern —like terraform.tfstate, which matches *.tfstate— does not get copied into the container, whether or not it's present in your local working directory. Confirmed with the same test, in the opposite direction: a test terraform.tfstate file, present on disk but ignored by Git, simply didn't exist inside the container.
WHAT act's LOCAL CHECKOUT COPIES WHAT IT DOESN'T COPY
✅ Tracked files, with or without ❌ Files matching
uncommitted changes any .gitignore pattern
✅ New, untracked files, that do NOT (e.g., terraform.tfstate,
match .gitignore any *.tfstate)
This is exactly why this lesson's fixture needs a name that does not match *.tfstate: if it were called terraform.tfstate or any ignored variant, it wouldn't even reach the container where the guardrail gets tested.
Step 4 — The change that triggers the destruction
With the fixture ready, the scenario needs a real HCL change that, combined with that seeded state, produces a delete action on Shipments. Imagine someone at Andes Cargo, mistakenly thinking the table is being replaced by a new service (outside this guide's scope), opens a Pull Request that removes the resource entirely.
Empty out dynamodb.tf (the whole file, including the IAM policy that depended on the table, so the HCL stays valid):
# The Shipments table was decommissioned here by mistake — the file is
# intentionally left empty so the diff is exactly what this lesson
# describes: the resource is removed, not the whole file.
And remove the output that depended on that resource, in outputs.tf:
output "shipment_docs_bucket_arn" {
description = "ARN of the shipment-docs bucket."
value = module.shipment_docs_bucket.bucket_arn
}
output "process_shipment_manifest_function_name" {
description = "Name of the manifest-processing Lambda function."
value = aws_lambda_function.process_shipment_manifest.function_name
}
Confirm it with terraform validate (needs no state or connection):
terraform validate
What to expect (literal):
Success! The configuration is valid.
Important note for your own project: this change is exactly the kind of diff that would live on a feature branch, never on main — don't commit it. This lesson applies it directly on the working directory, without creating a new Git branch, because the purpose is exclusively testing the guardrail; in lesson 8 you're going to see the same pattern, applied and then reverted, with no permanent trace left in the history.
Step 5 — A dedicated workflow to test the guardrail
The "seed the state" step should never live inside ci.yml — no real Pull Request should, ever, push a fake state to the project. That's why this test lives in its own, separate workflow, triggered only by hand —the same pattern you already used with hello-andes-cargo.yml and the secrets test workflows in Module 1 and Module 2—:
.github/workflows/guardrail-demo.yml:
name: guardrail-demo
on: workflow_dispatch
jobs:
destroy-shipments-check:
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
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 init
run: terraform init -input=false
- name: Seed the state with the fixture (Shipments already exists)
run: terraform state push .github/guardrail-fixtures/shipments-already-applied.state.json
- name: Install tflocal
run: pip3 install --quiet --break-system-packages terraform-local
- name: Terraform plan (offline, against the seeded fixture)
run: tflocal plan -refresh=false -input=false -no-color -out=tfplan
- name: Guardrail — block any plan that destroys the Shipments table
run: |
tflocal show -json tfplan > plan.json
MATCH=$(grep -oE '"address":"aws_dynamodb_table\.shipments".{0,200}"actions":\[[^]]*\]' plan.json || true)
if echo "$MATCH" | grep -q '"delete"'; then
echo "::error::Guardrail failed: this plan destroys aws_dynamodb_table.shipments (Shipments). Blocking before apply."
echo "$MATCH"
exit 1
fi
echo "Guardrail passed: no destroy action found for aws_dynamodb_table.shipments."
Two details worth noting: -refresh=false in the plan step is what keeps this run completely offline —without it, Terraform would try to confirm the table's real status against LocalStack before calculating the plan, and would fail with the same kind of connection refused you already know—; and the guardrail itself is exactly the same code block, with no modification, you already added to ci.yml in Step 1 — proof you're validating the real logic, not a different version written just for this demo.
Step 6 — Running it: the job fails, for real, in red
rm -f terraform.tfstate
act workflow_dispatch -j destroy-shipments-check -W .github/workflows/guardrail-demo.yml
What to expect (literal output, executed to write this lesson):
[guardrail-demo/destroy-shipments-check] ⭐ Run Set up job
[guardrail-demo/destroy-shipments-check] ✅ Success - Set up job
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Check out andes-cargo-infra
[guardrail-demo/destroy-shipments-check] ✅ Success - Main Check out andes-cargo-infra [58.503167ms]
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Set up Terraform
[guardrail-demo/destroy-shipments-check] ✅ Success - Main Set up Terraform [2.669285875s]
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Terraform init
[guardrail-demo/destroy-shipments-check] ✅ Success - Main Terraform init [14.325724792s]
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Seed the state with the fixture (Shipments already exists)
[guardrail-demo/destroy-shipments-check] ✅ Success - Main Seed the state with the fixture (Shipments already exists) [729.188333ms]
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Install tflocal
[guardrail-demo/destroy-shipments-check] ✅ Success - Main Install tflocal [5.643428375s]
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Terraform plan (offline, against the seeded fixture)
[guardrail-demo/destroy-shipments-check] | Plan: 10 to add, 0 to change, 1 to destroy.
[guardrail-demo/destroy-shipments-check] ✅ Success - Main Terraform plan (offline, against the seeded fixture) [4.476598084s]
[guardrail-demo/destroy-shipments-check] ⭐ Run Main Guardrail — block any plan that destroys the Shipments table
[guardrail-demo/destroy-shipments-check] ❗ ::error::Guardrail failed: this plan destroys aws_dynamodb_table.shipments (Shipments). Blocking before apply.
[guardrail-demo/destroy-shipments-check] | "address":"aws_dynamodb_table.shipments","mode":"managed","type":"aws_dynamodb_table","name":"shipments","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["delete"]
[guardrail-demo/destroy-shipments-check] ❌ Failure - Main Guardrail — block any plan that destroys the Shipments table [2.03995275s]
[guardrail-demo/destroy-shipments-check] ⭐ Run Complete job
[guardrail-demo/destroy-shipments-check] ✅ Success - Complete job
[guardrail-demo/destroy-shipments-check] 🏁 Job failed
Error: Job 'destroy-shipments-check' failed
Plan: 10 to add, 0 to change, 1 to destroy. — unlike every other plan in this guide up to now, this number does include a real destruction: the Shipments table, seeded as already existing by the fixture, and absent from the HCL. The other 10 resources (out of the original 12, not counting the table or the IAM policy that depended on it, both removed from the HCL) still show up as creations, because the fixture only declared the table as applied — nothing else.
The guardrail found exactly what it had to find: the line captured by the grep —"actions":["delete"]— confirms, with the same text Terraform itself produced, that this plan destroys the table. The job ended red, with the exact error message you wrote in Step 1, before any chance existed for this plan to ever reach a real apply.
Restoring the project to its healthy state
Before closing this lesson, undo Step 4's change —the destructive HCL should never stay as the project's permanent state—:
git checkout -- dynamodb.tf outputs.tf
rm -f terraform.tfstate tfplan plan.json
git status
What to expect (literal):
On branch main
nothing to commit, working tree clean
git checkout -- <file> restores each file to its committed version, discarding Step 4's uncommitted changes — exactly right for a change that was never approved, never merged, and should never have touched main. What does stay, permanently, is the guardrail added to ci.yml in Step 1, and the guardrail-demo.yml workflow along with its fixture — this lesson's two real pieces.
git add .github/workflows/ci.yml .github/workflows/guardrail-demo.yml .github/guardrail-fixtures/
git commit -m "ci.yml: add a guardrail step that blocks any plan destroying the Shipments table; add guardrail-demo.yml to test it against a seeded fixture"
Common mistakes
Writing the fixture with a name ending in .tfstate (configuration-based, this lesson's central finding). What happens: someone names the file shipments.tfstate instead of shipments-already-applied.state.json, and terraform state push works perfectly on the local disk, but the checkout step inside act never copies it into the container —the Seed the state with the fixture step fails with "no such file or directory." How to spot it: check the .gitignore pattern (*.tfstate) against your file's exact name. How to fix it: any name that doesn't contain the word "tfstate" in a matching pattern works — this lesson uses .state.json, on purpose, so it's clear it's a Terraform state without triggering .gitignore.
Forgetting -refresh=false in guardrail-demo.yml's plan (configuration-based, revisit Module 3). What happens: someone removes that flag, and the plan step tries to confirm the table's real status against LocalStack before calculating the diff, failing with the same connection refused you already know from every apply attempt without LocalStack running. How to fix it: -refresh=false is exactly what keeps this test offline — without it, the seeded fixture isn't enough, because Terraform tries to verify against the real provider anyway before trusting the local state.
Leaving Step 4's destructive change committed on main (workflow-based, potentially serious if repeated on a real project). What happens: someone, after running the guardrail demo, forgets the git checkout -- dynamodb.tf outputs.tf step, and the project ends up with the Shipments table permanently removed from the HCL. How to spot it: git status would show uncommitted changes in dynamodb.tf/outputs.tf, or —worse— a real commit with that content. How to fix it: always restore the affected files before moving on to lesson 8, exactly as this lesson shows — the destructive change exists only to test the guardrail, never to stay.
Exercises
Exercise 1 — Reconstruct the guardrail from memory. Without looking at this lesson, write (on paper or in an editor) the guardrail's run: block's four lines, in the correct order, and explain what each one does.
See solution
1. tflocal show -json tfplan > plan.json — converts the binary plan to readable JSON. 2. MATCH=$(grep -oE '...' plan.json || true) — searches for the text fragment corresponding to aws_dynamodb_table.shipments and its actions field. 3. if echo "$MATCH" | grep -q '"delete"'; then ... exit 1; fi — if that fragment contains "delete", prints an error and fails the step. 4. echo "Guardrail passed: ..." — if exit 1 didn't trigger, confirms the guardrail passed. If you reconstructed this without looking, you understood the complete mechanism, not just copied it.
Exercise 2 — Explain why the fixture only declares the Shipments table, not the other eleven resources. A colleague asks why you didn't seed a complete state, with all 12 resources applied. Answer them with this lesson's exact reason.
See solution
A complete answer sounds, roughly, like this: "This fixture's only purpose is giving the guardrail something real to evaluate: a plan with a delete action on Shipments. For that, it's enough for the state to know that specific table already exists — there's no need to simulate the other eleven resources, which would still show up as 'to add' in the plan anyway, without affecting the guardrail's logic at all, since it only looks at the fragment corresponding to the table. Seeding only what's strictly necessary keeps the fixture simpler and easier to maintain, without losing any of the test's validity."
Exercise 3 — Predict the result if the guardrail searched for "actions":["create"] instead of "delete". Without running anything, what would happen if someone mistakenly changed the grep's condition to search for "create" instead of "delete"? Run Step 2 mentally on that modified guardrail.
See solution
The guardrail would fail every normal ci.yml run, even with no destruction attempt at all — because the 12-resource plan, on any run without a real apply completed, always contains "actions":["create"] for Shipments. It would be the equivalent of a metal detector that goes off for any object, not just the prohibited one: technically it "works" (always blocks), but stops distinguishing a dangerous change from a normal one, making the entire pipeline unusable. It's a good reminder of why the grep's exact condition —which action, on which resource— matters just as much as the mechanism itself.
Summary and next step
In this lesson you built this guide's first real automated guardrail: a grep over terraform show -json, added to ci.yml, tested in two genuine scenarios. In the first —a normal plan, with 12 creations— the guardrail passed, confirmed with act's literal output. In the second —a plan calculated against a purposely seeded state fixture, alongside an HCL change that removes the table— the guardrail genuinely failed, stopping the job before any chance of applying. Along the way you confirmed a real finding about how act reflects your local working directory, with the exact limit .gitignore imposes.
Before moving on you should be able to: explain every line of the guardrail without looking at the file; describe why testing a delete action needs a seeded state on this specific project; and precisely locate why this grep, though real and functional, doesn't replace a policy system like conftest.
Lesson 8 —this module's project— integrates this guardrail into Andes Cargo's complete pipeline and tests it once more, on the complete narrative thread: a harmless change that passes, and an attempt to destroy Shipments the entire pipeline stops before applying.
Resources
- Terraform Docs — Command: show — official reference for
terraform show -json, this lesson's central command. - Terraform Internals — JSON Output Format — the public, stable schema of the JSON
terraform show -jsonproduces, including theresource_changes[].change.actionsfield the guardrail evaluates. - Terraform Docs — Command: state push — official reference for the command that seeds this lesson's
fixture. - This guide's Module 6 (
06-guardrails-of-apply-conftest-named.md) — the previous lesson, with the exact difference between thisgrepand a real policy system.