Hedronite · Go Lesson · Polyglot-Dev / Go · Tue 2026-09-08

Go terraform-json plan parser — census and check results

Terratest owns process exit. A small Go library owns typed plan JSON. Keep those jobs apart.

Lesson Class: DevOps-Go (non-terratest companion)
API: hashicorp/terraform-json Plan decode
Paired Ops: check-block module under inspection
Paired Dev: terratest InitAndPlanE
Grounding: Brikman Plan testing · Lab 07
Decode
Typed Plan over map scraping.
Report
Exit 0 census; gate elsewhere.
Boundary
No terratest import in library.
Typed decode beats silent zero counts.

<!-- hal:authoritative:yaml -->

Terratest owns process exit. A small Go library owns typed plan JSON. Keep those jobs apart.

§I — Frame

Today's Dev seat is terratest: prove the precondition fails. Today's Ops seat writes the HCL. The quatro Go lane must not clone either.

09-05 already built a Python census over resource_changes for apply-risk reports. That schema still matters. Today implement the Go twin with github.com/hashicorp/terraform-json, and extend the report to check results when the plan JSON includes them.

There is no terraform-json tome on the shelf. Cite Brikman Plan testing and Plan files for why the artifact exists. Cite Lab 07 for the module under inspection. Treat library types as primary tooling knowledge and log the tome gap.

§II — Language idiom: Plan decode

package planreport

import (
  "encoding/json"
  "fmt"
  "io"
  "strings"

  tfjson "github.com/hashicorp/terraform-json"
)

type Census struct {
  Creates  int
  Updates  int
  Deletes  int
  Replaces int
  Checks   []CheckRow
}

type CheckRow struct {
  Address string
  Status  string
  Message string
}

func FromShowJSON(r io.Reader) (*Census, error) {
  var plan tfjson.Plan
  if err := json.NewDecoder(r).Decode(&plan); err != nil {
    return nil, err
  }
  out := &Census{}
  for _, rc := range plan.ResourceChanges {
    if rc == nil || rc.Change == nil {
      continue
    }
    actions := rc.Change.Actions
    switch {
    case actions.Create():
      out.Creates++
    case actions.Update():
      out.Updates++
    case actions.Delete():
      out.Deletes++
    case actions.Replace():
      out.Replaces++
    }
  }
  // CheckResults fields vary by terraform-json version; normalize defensively.
  if plan.CheckResults != nil {
    for addr, cr := range plan.CheckResults {
      status := "unknown"
      msg := ""
      if cr != nil {
        status = string(cr.Status)
        if len(cr.Objects) > 0 && cr.Objects[0] != nil {
          msg = strings.TrimSpace(fmt.Sprint(cr.Objects[0].FailureMessages))
        }
      }
      out.Checks = append(out.Checks, CheckRow{Address: addr, Status: status, Message: msg})
    }
  }
  return out, nil
}

Pin terraform-json to a version that matches your Terraform minor line. The plan schema evolves. Decode into the typed struct rather than map[string]any so field renames break compile time instead of silent zero values.

§III — CLI wiring

package main

import (
  "fmt"
  "os"

  "example.com/planreport"
)

func main() {
  // usage: planreport < terraform show -json tfplan.out
  c, err := planreport.FromShowJSON(os.Stdin)
  if err != nil {
    fmt.Fprintf(os.Stderr, "decode: %v\n", err)
    os.Exit(2)
  }
  fmt.Printf("creates=%d updates=%d deletes=%d replaces=%d\n",
    c.Creates, c.Updates, c.Deletes, c.Replaces)
  for _, ch := range c.Checks {
    fmt.Printf("check %s status=%s %s\n", ch.Address, ch.Status, ch.Message)
  }
}

Pipeline:

terraform plan -out=tfplan.out
terraform show -json tfplan.out | ./planreport

Exit codes: 0 after a successful report even when checks failed in Terraform (mirrors 09-05 census-versus-policy split). A separate gate job can exit non-zero when Status is fail. Do not collapse report and gate into one binary unless the README says so.

§IV — What preconditions look like in JSON versus checks

A failing precondition typically prevents a successful plan file. Your report tool may receive nothing because terraform plan -out never wrote the file. Terratest owns that failure path.

A failing check often still produces a plan file with check results marked fail or error while resource changes remain. The report tool shines here: print the check address and message into the PR.

Resource changes still matter. Replaces are the expensive line. Count them the way the Python census did. Go's typed actions helpers reduce stringly bugs.

§V — Library boundaries with terratest

cmd/planreport/        # binary for humans and PR bots
planreport/            # FromShowJSON + Census types
test/                  # terratest; may import planreport

Forbidden: planreport importing terratest. Forbidden: copying JSON decode logic into every _test.go. Shared decode, separate runners.

When a terratest happy-path plan succeeds, write the plan JSON to a temp file and call FromShowJSON to assert Creates/Replaces expectations. That is complementary coverage, not duplication of InitAndPlanE error asserts.

§VI — Relation to 09-05 Python census

Concern09-05 PythonToday Go
Schemaresource_changes actionstyped tfjson.Plan
Checksnot spentCheckResults rows
Exit policyalways 0 for censusalways 0 for report
Pairingremote_state Opscheck-block Ops

Do not rewrite the Python tool in Go for sport. Add check awareness and typed decoding. If both tools ship in the same pipeline, agree on markdown section order so PR comments stay stable.

§VII — Error handling and version skew

If decode fails, exit 2 and print the Terraform version used to create the plan. Schema skew is the usual cause. Upgrade terraform-json or regenerate the plan with a matching CLI.

If ResourceChanges is empty and checks are empty, print an explicit "empty plan" line. Silence looks like a bug.

If check result maps are nil on older plans, treat as zero checks, not an error. Older Terraform versions omit the field.

§VIII — Closing

Ship planreport as a tiny module. Wire it after terraform plan in CI. Link Ops for the HCL under inspection. Link terratest Dev for the failure exit path you do not re-implement. Link Cert for the taxonomy of checks versus policy.

Log the missing terraform-json tome. Until it arrives, Brikman Plan testing plus Lab 07 remain the vault anchors.

§VII.B — Markdown report for pull requests

func (c *Census) Markdown() string {
  var b strings.Builder
  b.WriteString("### Terraform plan census\n\n")
  fmt.Fprintf(&b, "| create | update | delete | replace |\n|---:|---:|---:|---:|\n| %d | %d | %d | %d |\n\n",
    c.Creates, c.Updates, c.Deletes, c.Replaces)
  if len(c.Checks) == 0 {
    b.WriteString("_No check results in this plan JSON._\n")
    return b.String()
  }
  b.WriteString("### Checks\n\n")
  for _, ch := range c.Checks {
    fmt.Fprintf(&b, "- `%s` : **%s** %s\n", ch.Address, ch.Status, ch.Message)
  }
  return b.String()
}

Note the structural em-dash in the markdown bullet template above is a table-of-checks separator for humans reading GitHub. Keep body prose free of em-dash pauses.

Post the markdown with gh pr comment or your CI annotation API. Never dump raw JSON into the comment when engineers need counts.

§VII.C — Sensitive values

Plan JSON can contain sensitive values when providers mark them. Do not log full plan JSON to public CI logs. Stream through planreport and print counts plus check addresses only. If you must print a resource address that includes a secret name, redact.

Brikman's Plan files section warns about plan artifacts as secret carriers. Treat tfplan.out like a credential file: short retention, restricted ACLs, no artifact upload to public buckets.

§VII.D — Smoke test without AWS

Feed a checked-in minimal plan JSON fixture under testdata/empty-plan.json and testdata/with-checks.json. Unit test FromShowJSON without Terraform installed. Keep one integration smoke that shells to Terraform when RUN_TF=1.

That pattern keeps laptop tests fast and CI honest.

§VIII.B — Concrete walk against Lab 07

  1. Copy Lab 07 into a scratch directory.
  2. Fix it until terraform test passes (Ops/Cert already know the intended end state).
  3. terraform plan -out=tfplan.out with name_prefix=ab to provoke the quality check if still advisory.
  4. terraform show -json tfplan.out | planreport.
  5. Confirm the census line prints and the check row names name_prefix_quality or the equivalent address.

If step 3 fails on a precondition instead, you are on the prod-micro matrix. Switch to Dev's terratest path. The report tool is for successful plan files.

§VIII.C — Module API stability

Export only:

func FromShowJSON(r io.Reader) (*Census, error)
func (c *Census) Markdown() string

Keep CheckRow and Census fields exported for tests. Hide decode quirks inside the package. When terraform-json adds fields, extend Census without breaking Markdown consumers.

Add a go.mod with a tagged module path under your private prefix. Consumers in terratest fixtures should require a semver tag, not a fake example.com path from the lesson sketch.

§VIII.D — Refusal list

§VIII.E — Why typed decode beats map scraping

09-05 Python used dict access on resource_changes. That works. It also tolerates typos until a night when actions is missing and the census under-counts replaces.

Go with terraform-json makes Change.Actions.Replace() a method. Invalid field use fails compilation. That is the point of this companion on a terratest day: keep Go strong for ops tooling without writing another test runner.

When HashiCorp publishes schema notes for a new plan version, upgrade the module, run go test ./..., and fix compile breaks. Prefer that pain over silent markdown lies in PR comments.

Examine the census line on a known fixture before trusting CI. Ship the binary. Document stdin usage. Link the four-bundle theme. Then leave the parser alone until the next Terraform minor forces a bump.

Related