terraform-stack-plan

module
v0.5.10 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 10, 2026 License: Apache-2.0

README

tfstackplan

Render many Terraform plan.json files — one per stack, as produced by a Terramate / Terragrunt / multi-root-module monorepo — into a single, reviewer-friendly markdown report for a PR comment.

It's the multi-stack rollup that single-plan renderers like tfplan2md don't cover: a tier-level summary table plus a collapsed per-stack drill-down, with optional classification to flag stacks that need extra review (IAM, destructive changes, …).


What it looks like

A run over eight stacks with classification enabled, rendered the way GitHub shows it in a PR comment: a scannable summary table, then a per-stack drill-down. Inside a stack, each resource is its own row within an indented blockquote bar — so it's always clear which stack/resource you're reading. Small changes are shown expanded; big ones collapse to a row you click to open. (The stacks below are shown expanded for illustration.)

Terraform plan — nonprod (8 stacks changed)
Stack Add Change Destroy Replace Categories
platform/nonprod 0 4 0 0 🔐 iam
service-projects/app-dev 4 3 0 0 ✅ safe
data/warehouse 0 0 6 0 💣 destructive
networking/shared-vpc 0 5 0 2 💣 destructive
observability/grafana 5 6 0 0 ✅ safe
📁 platform/nonprod · 🔐 iam · 4 change
✏️ google_project_iam_member.data_engineers
    1 changed
~ role = "roles/viewer" → "roles/editor"
✏️ google_storage_bucket.tfstate
    2 changed
~ retention_days = 7 → 30
~ labels (yaml):
 env: nonprod
+team: platform

Scalars render as aligned ~ path = old → new leaves; structured attributes (here labels) render as a contextual diff. Creates and deletes look the same — one open row per resource showing its attributes:

📁 data/warehouse · 💣 destructive · 6 destroy
➖ google_bigquery_dataset.legacy_events
    2 attrs
- location = "us-central1"
- name     = "legacy_events"

Structured values (JSON/YAML strings and native HCL maps/lists) render as a contextual diff — canonically re-formatted, 2 lines of context, changed lines as -/+, tagged with the kind. Small ones stay inline (open); big ones collapse to a closed row:

📁 observability/grafana · ✅ safe · structured fields (excerpt)
✏️ kubernetes_manifest.ingress
    1 changed
~ manifest (yaml):
 spec:
-    key_00: old
-    key_01: old
+    key_00: new
+    key_01: new
     key_02: old
     key_03: old
✏️ kubernetes_manifest.configmap
    1 changed
~ manifest (yaml):
 spec:
-    key_00: old
-    key_01: old
+    key_00: new
+    key_01: new
  … (many paths changed → folds to a closed row)

The first line of the real output is an HTML-comment marker (<!-- tfstackplan:nonprod -->, invisible above) that CI uses to upsert one comment per tier.

Key render behaviours:

  • Stacks read as section headers (📁 + bold name); every resource is a uniform <details> row inside the stack's blockquote bar, giving a clear stack → resource hierarchy.
  • Row layout: an emoji action glyph + the address on line 1 (glued with &nbsp; so a long path can't orphan the icon), with the descriptor (N changed, replace, …) hanging on an indented line below.
  • Size-based folding: a row is open when its body is small (≤ ~10 lines), collapsed when big — the same rule for creates, deletes, and updates.
  • Aligned changes: ~ path = old → new, with = aligned and nested maps keeping their name via dotted paths (+ labels.team = "platform"). Diff-body markers stay ASCII +/-/~ so GitHub colours them.
  • Structured values (JSON/YAML strings and native HCL maps/lists) render as a contextual diff — the value canonically re-formatted, 2 lines of context, changed lines as -/+, tagged with its kind (~ policy (json):). Small diffs stay inline; big ones collapse the row.
  • State operations surface as rows too: moved (↪️ addr / moved from …), imported (📥 addr / imported · id=…, the id monospaced), and removed-from-state (⏏️ addr / forgotten). These have no summary-table columns; their counts append to the stack's row text.
  • --details open|auto overrides the per-row default; zero-only columns are dropped; without a classification policy the Categories column disappears.
More examples

Larger reports stay under GitHub's 65,536-byte comment cap by degrading the biggest diffs first, then dropping detail. These files are real tool output (regenerated and byte-checked by go test ./cmd/tfstackplan):

  • examples/big-plan.md — 58 changes across 8 stacks, full detail, fits the default 60 KB budget.
  • examples/over-budget-degraded.md — tighter budget: large diffs collapse to one-line summaries (~ data · text · 120 lines · 240 changed (hidden to fit size limit)), small diffs kept.
  • examples/over-budget-summary-only.md — tighter still: all <details> dropped, summary table + a notice retained.
  • examples/over-budget-minimal.md — past every simplification and still over budget: a one-line aggregate is emitted and the tool exits non-zero so CI can surface it.
  • examples/state-ops.md — moved (↪️), imported (📥), and removed-from-state / forget (⏏️) resources; and contextual diffs for nested JSON, YAML, and native HCL blocks (2 lines of context, -/+ changes), in small (inline) and big (folded) form.
  • examples/long-names.md — deeply nested module paths and for-each ["key"] indices (a long member principal, an empty [""] key) plus a long import id, rendered expanded so you can judge how long row summaries wrap to two lines.

Install

go install github.com/Fluent-Health/terraform-stack-plan/cmd/tfstackplan@latest

Or grab a prebuilt binary from the Releases page, or build from source: go build -o tfstackplan ./cmd/tfstackplan.


Usage

Each stack contributes one tfplan.json (terraform show -json plan.bin). Collect them under one directory that mirrors your stack tree — out/<stack>/tfplan.json — and point the tool at it:

tfstackplan --plans-dir out/ \
  --title  "Terraform plan — nonprod" \
  --marker tfstackplan:nonprod \
  --output report.md

Each tfplan.json found defines a stack; its name is the directory holding it, relative to --plans-dir (so out/platform/nonprod/tfplan.json → platform/nonprod). Stacks render in alphabetical order. An empty (or absent) set of plans renders a header-only "0 stacks changed" report.

Driving from your orchestrator

Terramate — a per-stack script writes each plan into the central tree (terramate.stack.path.to_root climbs back to the repo root), then one render step rolls them up:

script "plan-report" {
  job {
    commands = [
      ["terraform", "plan", "-out", "tfplan.bin"],
      ["sh", "-c", "mkdir -p ${terramate.stack.path.to_root}/out/${terramate.stack.path.relative} && terraform show -json tfplan.bin > ${terramate.stack.path.to_root}/out/${terramate.stack.path.relative}/tfplan.json"],
    ]
  }
}
terramate script run plan-report
tfstackplan --plans-dir out/ --output report.md

Terragrunt — its native --json-out-dir already produces the right shape:

terragrunt run --all --filter-affected plan --json-out-dir out
tfstackplan --plans-dir out --output report.md

The source-aware links feature resolves each resource against <repo-root>/<stack name>, so it works automatically when out/ mirrors the stack tree (the default above). Set --repo-root if you run the tool from elsewhere.


Classification (optional)

Classification is computed by the tool, not supplied per stack: each stack's plan is matched against rules in an HCL policy file (--config, or auto-discovered .tfstackplan.hcl in the working directory). Every rule whose matcher fires contributes a category, so a stack carries the set of categories it matched; a stack that matches nothing is shown as the default.

# .tfstackplan.hcl  — repo policy, checked into git
classification {
  default {
    name = "safe"
    icon = "✅"
  }

  preset "iam" {            # built-in matcher; you only pick the icon
    icon = "🔐"
  }

  rule "destructive" {      # custom matcher
    icon      = "💣"
    actions   = ["delete"]
    min_count = 1
  }
}

Every matching preset/rule fires independently (a stack can carry several categories); declaration order controls the display order of the badges, so put the most important first.

Presets ship a maintained matcher so you don't hand-write regexes; only the icon is configurable:

Preset Matches Default icon
iam IAM resources on GCP (*_iam_{policy,binding,member,audit_config}), AWS (aws_iam_*), Azure (azurerm_role_{assignment,definition}). Any action, so an in-place policy update still flags as iam. 🔐

Custom rules take the category name from the block label, plus a small matcher:

Field Meaning Default
icon Glyph prepended to the category name none
resource_type_pattern Regex matched against each change's type .* (any)
actions A change matches only if ALL listed actions appear in it any action
min_count Minimum matching changes for the rule to fire 1

default can also be written as a bare string (default = "safe") when you don't want an icon.

Sidecar JSON for CI gating

--emit-classification-json hands CI the result as data — the sidecar nests per-stack categories under stacks plus a run-level summary:

tfstackplan --plans-dir out/ --config .tfstackplan.hcl \
            --output report.md --emit-classification-json classes.json
{
  "stacks": {
    "platform/nonprod": { "categories": [
      { "category": "iam",         "icon": "🔐", "attributes": { "project": ["fh-host-nonprod"] } },
      { "category": "destructive", "icon": "💣" }
    ]},
    "service-projects/app-dev": { "categories": [] }
  },
  "summary": { "categories": [
    { "category": "iam",         "icon": "🔐", "attributes": { "project": ["fh-host-nonprod", "fh-svc-dev"] } },
    { "category": "destructive", "icon": "💣" }
  ]}
}

Each stack lists its matched categories ([] when it matched nothing — the default/safe fallback is display-only and never appears here). icon is null when the category has none. summary.categories lists every category present across the run with the per-key sorted-unique union of its attributes — this is what a CI gate consumes (one category → its subjects). The flag is a no-op without classification.

Emitting matched attributes

A rule or preset can also surface attributes of the changes it matched, so CI can gate on which subjects triggered the category — e.g. the GCP projects with IAM changes:

classification {
  preset "iam" {
    icon            = "🔐"
    emit_attributes = ["project"]
  }
}

The sidecar then carries the sorted-unique, non-null values per stack:

{
  "stacks": {
    "platform/nonprod": { "categories": [
      { "category": "iam", "icon": "🔐", "attributes": { "project": ["fh-host-nonprod", "fh-svc-dev"] } }
    ]}
  }
}
  • Values come from the matched changes only (a safe stack emits nothing), read from each change's after (falling back to before for deletes).
  • Top-level scalar attributes only; nested paths are not supported.
  • Sensitive values are never emitted.
  • attributes is omitted when the firing rule declares no emit_attributes or no values were found.
  • For project specifically: when the matched changes carry none (e.g. bucket-scoped google_storage_bucket_iam_member), it falls back to the stack's unique project (the single distinct project across all the stack's changes). Ambiguous stacks (0 or >1 distinct projects) emit none — gates fail closed.
Cross-state move targets (--state-moves)

When a stack is the destination of a cross-state move (e.g. from an infra state-mover that runs terraform state mv -state=old.tfstate -state-out=new.tfstate), Terraform plans a create for the arriving resource. That create is a relocation — not a real mutation — and must not trip the per-project IAM gate.

Pass a JSON manifest listing the destination addresses per stack:

tfstackplan --plans-dir out/ --config .tfstackplan.hcl \
            --emit-classification-json classes.json \
            --state-moves moves.json
{
  "service-projects/migrated": [
    "module.moved.google_project_iam_member.a",
    "module.moved.google_storage_bucket_iam_member.logs"
  ]
}

Keys are the stack names as they appear in --plans-dir (i.e. the directory path of each tfplan.json relative to --plans-dir). Each listed address is treated as non-mutating: its planned create classifies the same way as an in-stack moved — it does not count toward any rule's min_count and its attributes are never emitted.

Absent flag is fail-safe: omitting --state-moves leaves classification completely unchanged. Every create is treated as a real create, so the gate is never silently bypassed.


Diff configuration (optional)

The same HCL file can tune per-attribute diffs via a diff {} block. The built-in renderer sniffs structured values (JSON / YAML / base64) and picks a sensible diff style; use a rule to force one when detection misfires.

diff {
  detect              = true   # auto-detect JSON/YAML/base64 (default: true)
  max_attribute_lines = 200    # optional skimmability ceiling; unset = fit decides

  rule {
    resource_type_pattern = "^kubernetes_manifest$"
    attribute             = "manifest"   # exact name or glob
    differ                = "yaml"
  }
}

The report can link out to your code and CI. URL templates live in the HCL policy; per-run values are supplied with --link-var key=value (repeatable) and --repo-root (base for computing file paths). The tool runs alongside the source, so it parses each stack's .tf (and modules resolved via .terraform/modules/modules.json) to find where each changed resource is declared and links the resource address to that file#Lline at the commit.

links {
  resource = "https://github.com/org/infra/blob/{sha}/{file}#L{line}"
  stack    = "https://github.com/org/infra/tree/{sha}/{stack_dir}"
  header {
    label = "Cloud Build #{build_id}"
    url   = "https://console.cloud.google.com/cloud-build/builds/{build_id}?project={project}"
  }
  header {
    label = "PR #{pr}"
    url   = "https://github.com/org/infra/pull/{pr}"
  }
}
tfstackplan --plans-dir out/ --config .tfstackplan.hcl \
  --repo-root . \
  --link-var sha=$COMMIT_SHA --link-var pr=$_PR_NUMBER \
  --link-var build_id=$BUILD_ID --link-var project=$PROJECT_ID
  • Three levels: header (a line of links under the title), stack (the stack heading → its directory at the commit), and resource (the resource address → its .tf declaration at the commit).
  • Template vars are tool-computed — {file}, {line}, {stack}, {stack_dir}, {type}, {name}, {address}, {module}, {sha_short} — or supplied via --link-var ({sha}, {build_id}, {pr}, {project}, …). A template that references a missing var renders empty, so that link is omitted — partially-configured runs degrade cleanly.
  • Stack source dir is <repo-root>/<stack name>, where the stack name is the directory of the tfplan.json relative to --plans-dir.
  • Fallback: a resource the tool can't resolve to a repo file (remote/cached module, un-init-ed stack, parse gap) falls back to the stack link, never a dead end. Deep-linking to the PR diff hunk isn't possible (GitHub limitation) — the resource link points at its block at {sha}.

CLI reference

tfstackplan --plans-dir DIR
            [--title TEXT] [--marker TEXT]
            [--config FILE]                 # HCL policy; default: auto-discover .tfstackplan.hcl
            [--max-bytes N]                 # default 60000; 0 disables
            [--details auto|open|closed]    # default closed (auto = open iff one stack changed)
            [--emit-classification-json FILE]
            [--state-moves FILE]            # JSON manifest of cross-state move targets (see below)
            [--repo-root DIR]               # base for link file paths (default ".")
            [--link-var key=value]          # link template var (repeatable)
            [--output FILE | -]             # default '-' (stdout)
            [--version]

--plans-dir is required; it is scanned for tfplan.json files. With no --config and no .tfstackplan.hcl present, classification is off, diffs use defaults, and no links are emitted.


Out of scope

  • Posting to GitHub / GitLab / Bitbucket — the tool writes markdown; your CI posts it. Keeps it a pure, offline-testable renderer.
  • Running terraform plan — inputs are pre-existing plan.json files.
  • Static-analysis rollup (Checkov, Trivy, SARIF) — possible later.

See docs/DESIGN.md for architecture and design rationale.

  • tfplan2md — single-plan markdown renderer; inspiration for the per-stack diff style.
  • Atlantis — PR-comment-driven Terraform CI; renders per-project, tied to its own workflow.
  • terraform-plan-summary — single-plan summary table; no multi-plan support.

Contributing

Contributions welcome — see CONTRIBUTING.md. For security issues, follow SECURITY.md rather than opening a public issue.

License

Licensed under the Apache License, Version 2.0 — see LICENSE.


Built and maintained by Fluent Health.

Directories

Path Synopsis
cmd
tfstackplan command
Command tfstackplan merges many Terraform plan.json files into one marker-keyed markdown report for a PR comment.
Command tfstackplan merges many Terraform plan.json files into one marker-keyed markdown report for a PR comment.
internal
classify
Package classify applies an ordered list of rules to a parsed stack and returns the set of matching categories.
Package classify applies an ordered list of rules to a parsed stack and returns the set of matching categories.
config
Package config loads and validates the HCL policy file (.tfstackplan.hcl), resolving classification presets+rules into a single ordered rule list while preserving source declaration order.
Package config loads and validates the HCL policy file (.tfstackplan.hcl), resolving classification presets+rules into a single ordered rule list while preserving source declaration order.
differ
Package differ turns a single attribute's before/after values into an ordered list of render variants (preferred → minimal).
Package differ turns a single attribute's before/after values into an ordered list of render variants (preferred → minimal).
events
Package events defines the typed, versioned protocol the CI runner speaks to the server: the lifecycle of a multi-stack Terraform execution (init → phase → per-stack updates → finalize) plus the apply-time gate exchange.
Package events defines the typed, versioned protocol the CI runner speaks to the server: the lifecycle of a multi-stack Terraform execution (init → phase → per-stack updates → finalize) plus the apply-time gate exchange.
fit
Package fit deterministically reduces a model.Report so its rendered size fits a byte budget, degrading the largest attribute diffs first, then falling back through a report-level terminal cascade.
Package fit deterministically reduces a model.Report so its rendered size fits a byte budget, degrading the largest attribute diffs first, then falling back through a report-level terminal cascade.
links
Package links resolves URL templates with {placeholder} substitution.
Package links resolves URL templates with {placeholder} substitution.
model
Package model holds the shared, budget-agnostic intermediate types that flow through the gather → fit → render pipeline.
Package model holds the shared, budget-agnostic intermediate types that flow through the gather → fit → render pipeline.
plan
Package plan parses a Terraform plan JSON document into a RawStack: reduced action counts plus the set of affected attributes for each change action.
Package plan parses a Terraform plan JSON document into a RawStack: reduced action counts plus the set of affected attributes for each change action.
plandir
Package plandir discovers per-stack Terraform plan JSON files under a single directory.
Package plandir discovers per-stack Terraform plan JSON files under a single directory.
presets
Package presets provides built-in named classification rule bundles that a repo can opt into without hand-writing regexes.
Package presets provides built-in named classification rule bundles that a repo can opt into without hand-writing regexes.
render
Package render turns a (possibly fit-reduced) model.Report into the final markdown document.
Package render turns a (possibly fit-reduced) model.Report into the final markdown document.
source
Package source indexes a stack's Terraform source so a plan resource can be linked to the file:line where it is declared.
Package source indexes a stack's Terraform source so a plan resource can be linked to the file:line where it is declared.
statemoves
Package statemoves loads the optional --state-moves manifest: per stack, the addresses being moved IN by an external cross-state move (infra's state-mover).
Package statemoves loads the optional --state-moves manifest: per stack, the addresses being moved IN by an external cross-state move (infra's state-mover).
store
Package store is the server's SQLite persistence layer: executions, their stack/edge subgraph, and per-(class,target) approval gate state.
Package store is the server's SQLite persistence layer: executions, their stack/edge subgraph, and per-(class,target) approval gate state.
store/migrations
Package migrations embeds the goose SQL migrations for the server store.
Package migrations embeds the goose SQL migrations for the server store.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL