029 Project 29: Terraform Plan Parser

Updated

September 8, 2026

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+create in 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.27

Prerequisite sample:

# in a terraform root
terraform plan -out=tfplan
terraform show -json tfplan > plan.json

Minimal 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

  1. Group by provider/type.
  2. Filter addresses by regex.
  3. Markdown report for PR comments.
  4. 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