029 Project 29: Terraform Plan Parser
029 Build a Terraform Plan Parser
Parse terraform show -json output and summarize create/update/delete/replace actions. This is the foundation for CI policy gates and human-readable plan digests.
plan.json -> decode resource_changes -> classify actions -> print + summary
Problem statement
tf-plan-parse <plan.json>
- Read Terraform plan JSON
- For each
resource_changes[], print address and actions - Detect replace (
delete+createin one change) - Print summary counts
- Exit 2 on usage/IO/JSON errors; 0 otherwise
Acceptance criteria
- Handles empty plans
- Counts create/update/delete/replace
- Replace not double-counted as delete+create in summary (your policy: document)
- Stable stdout suitable for logs
- Stdlib only
Setup
mkdir tfplanparse && cd tfplanparse
go mod init example.com/tfplanparse
# go 1.27Prerequisite sample:
# in a terraform root
terraform plan -out=tfplan
terraform show -json tfplan > plan.jsonMinimal fixture testdata/plan.json:
{
"resource_changes": [
{
"address": "aws_instance.web",
"type": "aws_instance",
"change": { "actions": ["create"] }
},
{
"address": "aws_instance.old",
"type": "aws_instance",
"change": { "actions": ["delete", "create"] }
}
]
}Full main.go
package main
import (
"encoding/json"
"fmt"
"os"
)
type Plan struct {
ResourceChanges []ResourceChange `json:"resource_changes"`
}
type ResourceChange struct {
Address string `json:"address"`
Type string `json:"type"`
Change struct {
Actions []string `json:"actions"`
} `json:"change"`
}
func classify(actions []string) string {
if len(actions) == 2 && actions[0] == "delete" && actions[1] == "create" {
return "replace"
}
if len(actions) == 1 {
return actions[0]
}
if len(actions) == 0 {
return "no-op"
}
return "mixed:" + fmt.Sprint(actions)
}
func analyze(p Plan) (counts map[string]int, lines []string) {
counts = map[string]int{
"create": 0, "update": 0, "delete": 0, "replace": 0, "no-op": 0, "other": 0,
}
for _, rc := range p.ResourceChanges {
kind := classify(rc.Change.Actions)
switch kind {
case "create", "update", "delete", "replace", "no-op":
counts[kind]++
default:
counts["other"]++
}
lines = append(lines, fmt.Sprintf("%-8s %s", kind, rc.Address))
}
return counts, lines
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: tf-plan-parse <plan.json>")
os.Exit(2)
}
b, err := os.ReadFile(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
var p Plan
if err := json.Unmarshal(b, &p); err != nil {
fmt.Fprintln(os.Stderr, "json:", err)
os.Exit(2)
}
counts, lines := analyze(p)
for _, line := range lines {
fmt.Println(line)
}
fmt.Printf("summary create=%d update=%d delete=%d replace=%d no-op=%d other=%d\n",
counts["create"], counts["update"], counts["delete"], counts["replace"], counts["no-op"], counts["other"])
}Run and verification
go run . testdata/plan.json
# REPLACE aws_instance.old
# create aws_instance.web
# summary create=1 ...Tests
package main
import "testing"
func TestClassifyReplace(t *testing.T) {
if classify([]string{"delete", "create"}) != "replace" {
t.Fatal()
}
}
func TestAnalyze(t *testing.T) {
p := Plan{ResourceChanges: []ResourceChange{
{Address: "a", Change: struct {
Actions []string `json:"actions"`
}{Actions: []string{"create"}}},
}}
// fix: use proper struct init
_ = p
}Better test:
func TestAnalyzeCounts(t *testing.T) {
var p Plan
p.ResourceChanges = []ResourceChange{
{Address: "x", Change: struct {
Actions []string `json:"actions"`
}{Actions: []string{"create"}}},
}
// Simpler: set Actions via temporary
rc := ResourceChange{Address: "x"}
rc.Change.Actions = []string{"create"}
p.ResourceChanges = []ResourceChange{rc}
rc2 := ResourceChange{Address: "y"}
rc2.Change.Actions = []string{"delete", "create"}
p.ResourceChanges = append(p.ResourceChanges, rc2)
c, _ := analyze(p)
if c["create"] != 1 || c["replace"] != 1 {
t.Fatalf("%v", c)
}
}go test ./...Stretch goals
- Group by provider/type.
- Filter addresses by regex.
- Markdown report for PR comments.
- Read plan from stdin (
-).
Pitfalls
| Pitfall | Fix |
|---|---|
Binary tfplan not JSON |
always terraform show -json |
| Plan format version drift | pin terraform; tolerate missing fields |
| Counting replace as delete+create | classify first |
Learning goals
- Deterministic IaC review tooling
- JSON plan shape for Terraform
- Building blocks for policy-as-code