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.
<!-- 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
| Concern | 09-05 Python | Today Go |
|---|---|---|
| Schema | resource_changes actions | typed tfjson.Plan |
| Checks | not spent | CheckResults rows |
| Exit policy | always 0 for census | always 0 for report |
| Pairing | remote_state Ops | check-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
- Copy Lab 07 into a scratch directory.
- Fix it until
terraform testpasses (Ops/Cert already know the intended end state). terraform plan -out=tfplan.outwithname_prefix=abto provoke the quality check if still advisory.terraform show -json tfplan.out | planreport.- Confirm the census line prints and the check row names
name_prefix_qualityor 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
- Do not call AWS APIs from
planreport. Inventory belongs in yesterday's SDK lessons. - Do not shell out to
terraforminsideFromShowJSON. The caller owns the CLI. - Do not parse HCL. Plan JSON is the contract.
- Do not fail the report binary on check failures by default.
- Do not duplicate terratest precondition stderr asserts here.
§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
- Paired Ops: Archmagus-Stack/01-Earth-DevOps/Synthesis-Lessons/2026-09-08-terraform-check-blocks-preconditions-postconditions-on-aws-resources/lesson.md
- Paired Dev (terratest): Archmagus-Stack/Polyglot-Dev/Go/2026-09-08-terratest-asserting-check-block-failures-and-plan-tests/lesson.md
- Prior Python census: Archmagus-Stack/Polyglot-Dev/Python/2026-09-05-python-terraform-show-json-resource-changes-census-for-apply-risk-reports/lesson.md
- Syllabus ledger: Archmagus-Stack/Polyglot-Dev/_syllabus-ledger.md
- Lab 07: Archmagus-Stack/Sovereign-Bootcamp/tfpro-labs/labs/07-validation-checks-tests-broken/README.md