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.)
| 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
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"
}
}
Links (optional)
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.