terraform-stack-plan

module
v0.9.11 Latest Latest
Warning

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

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

README

tfstackplan

One tool with four faces for multi-stack Terraform CI — a monorepo that produces N plans per PR (Terramate / Terragrunt / multi-root-module):

  • render — merge many plan.json files (one per stack) into a single, reviewer-friendly markdown PR comment, with optional classification. A pure, offline renderer — it never runs terraform and never posts.
  • run — the CI driver that wraps your terramate script run, detects the changed stacks, runs plan/apply/verify, renders + classifies in-process, and reports the execution lifecycle to the control plane.
  • serve — the control plane: a live dependency-DAG UI, approval gates, one GitHub check run per environment, and SSE-tailed per-stack logs.
  • state — declarative cross-stack Terraform state moves, applied as part of the normal apply.

Module path: github.com/Fluent-Health/terraform-stack-plan.

render is fully standalone — you can use it on its own without ever touching run / serve / state. The other faces layer a control plane on top while Terraform keeps executing in your CI under your identities.


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"

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.
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):


Install / build

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

A prebuilt binary is on the Releases page. The release also ships a multi-arch, distroless Cloud Run container (its entrypoint is serve) pushed to GHCR — the binary is fully static (pure-Go SQLite, no cgo) and embeds its assets, so the image needs no runtime files.

asdf

This repo is its own asdf plugin — the hook scripts live in bin/, so there is no separate plugin repo to maintain. The plugin downloads the prebuilt release binary (verifying its .sha256); no Go toolchain is needed on the target machine.

asdf plugin add tfstackplan https://github.com/Fluent-Health/terraform-stack-plan.git
asdf install tfstackplan latest      # or a pinned version, e.g. 0.8.1
asdf set tfstackplan 0.8.1           # writes .tool-versions

linux/darwin on amd64/arm64. asdf won't auto-add the plugin from a .tool-versions, so each machine runs asdf plugin add … once; asdf install thereafter honours the pinned version.


render — plan.json files → one markdown comment

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 render --plans-dir out/ \
  --title  "Terraform plan — nonprod" \
  --marker tfstackplan:nonprod \
  --output report.md

A bare flags-first invocation (tfstackplan --plans-dir …) also renders, for backward compatibility. 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 alphabetically. An empty (or absent) set of plans renders a header-only "0 stacks changed" report.

Flags
tfstackplan render --plans-dir DIR
            [--title TEXT] [--marker TEXT]
            [--config FILE]                 # HCL policy; default: auto-discover .tfstackplan.hcl
            [--max-bytes N]                 # default 60000; 0 disables the budget
            [--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 `state`)
            [--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. With no --config and no .tfstackplan.hcl present, classification is off, diffs use defaults, and no links are emitted — the tool degrades gracefully with zero config.

Classification (presets + rules)

Classification is computed by the tool: each stack's plan is matched against rules in the HCL policy. Every rule whose matcher fires contributes a category (a stack carries the set it matched, in declaration order); a stack matching nothing shows the default.

classification {
  default { name = "safe", icon = "✅" }   # or shorthand: default = "safe"

  preset "iam" {            # built-in matcher (you only pick the icon)
    icon            = "🔐"
    emit_attributes = ["project"]   # surface matched subjects for CI gating
  }

  rule "destructive" {      # custom matcher; name = block label
    icon                  = "💣"
    resource_type_pattern = ".*"        # default: any type
    actions               = ["delete"]  # matches iff ALL listed actions appear
    min_count             = 1
  }
}

The iam preset matches IAM resources on GCP (*_iam_{policy,binding,member,audit_config}), AWS (aws_iam_*), and Azure (azurerm_role_{assignment,definition}); any action, so an in-place policy update still flags. Classification considers only changes that mutate the real resource (add/change/destroy/replace) — pure move/import/forget state operations never contribute a category.

--emit-classification-json hands CI the result as data: per-stack categories under stacks, plus a run-level summary with the per-key sorted-unique union of emitted attributes — what a gate consumes. See examples/.tfstackplan.hcl for a complete classification + diff policy.

The byte budget

GitHub's 65,536-byte comment cap counts the raw markdown source (collapsed <details> still counts). fit keeps the report under --max-bytes (default 60,000) by degrading the largest diff first — preferred → summary → hidden — deterministically (byte-identical re-runs, so CI upserts don't churn). If even all-minimal overflows, it cascades: summary-only → one-line aggregate → a best-effort floor that exits non-zero. The summary table and classification are never reduced.

A diff {} block tunes per-attribute diffs (force a differ when type detection misfires); a links {} block adds header/stack/resource links (resource → its .tf declaration at the commit, resolved by parsing the source tree). Both live in the same .tfstackplan.hcl; see examples/.tfstackplan.hcl.


run — the CI driver

run wraps your terramate script run and reports the execution to the control plane. It reads its context from the environment the orchestrator sets (internal/runner/env.go); an empty server URL is a full no-op, so local runs and run tick work offline:

Env var Meaning
TFSTACKPLAN_SERVER control-plane base URL ("" = offline, no-op)
TFSTACKPLAN_TOKEN bearer secret for /api/*
TFSTACKPLAN_EXECUTION execution id this run reports under
TFSTACKPLAN_STACK current stack path (fallback for run tick --stack)
TFSTACKPLAN_ENVIRONMENT deployment environment for the execution
TFSTACKPLAN_PR PR number
TFSTACKPLAN_REPO owner/repo
TFSTACKPLAN_SHA head commit SHA

Server reporting is best-effort: a down or absent server degrades the build to "no live progress", never to failure. The apply-time gate check is the one fail-closed exception.

run plan
tfstackplan run plan --dir DIR
        [--changed]          # only plan changed stacks (default true)
        [--parallel N]       # parallel plan jobs (0 = terramate default)
        [--base REF]         # git base ref for change detection
        [--script NAME]      # terramate script name (default "plan")
        [--log-file NAME]    # per-stack log file the script tees (default tfstackplan.log; empty disables)
        [--config FILE]      # default: auto-discover .tfstackplan.hcl under --dir

Detects the changed stacks, registers the execution + dependency DAG (Init), runs the terramate plan script across the changed set (setting the TFSTACKPLAN_* env so each stack's run tick reports progress), gathers each stack's tfplan.json, renders + classifies in-process, derives the approval gates (each gating class × its emitted target values) and the moving stacks, and posts Finalize. Per-stack logs stream live to the server from the --log-file each stack's terramate script tees terraform output to.

run apply
tfstackplan run apply --dir DIR
        [--changed] [--base REF] [--script NAME]   # default script "apply"
        [--log-file NAME]
        [--state-lock]       # pessimistic GCS lock around cross-state moves (requires ADC)
  1. Fail-closed gate pre-check — asks the server whether the PR's gates are satisfied before touching terramate; a 409, any non-2xx, or an unreachable configured server blocks the apply (an unconfigured server is a no-op pass).
  2. Cross-state move pre-phase — executes any pending _tfsp_xmove.*.hcl manifests (see state); --state-lock wraps them in the pessimistic GCS lock. Fail-closed.
  3. Applies the changed stacks in dependency order (no --parallel), then revokes the PR's grants afterward (best-effort).
run verify
tfstackplan run verify --dir DIR [--changed] [--base REF] [--script NAME] [--log-file NAME]

Runs the terramate verify script (default) across changed stacks — no gate, read-only post-apply validation — and reports a verify/<env> check run with its own live page and per-stack Verify tab.

run tick
tfstackplan run tick [--stack PATH] [--status STATUS] [--detail TEXT]

The internal per-stack reporter the terramate scripts call between commands. It reads the execution context from the TFSTACKPLAN_* env, posts a best-effort update, and is a no-op offline or on any server error — a tick never fails the build.


serve — the control plane

tfstackplan serve [--config FILE] [--addr :8080]

serve ties the server together from the serve {} config block (see Configuration): opens the SQLite store, builds the real GitHub App client and the gcp-pam approval backend (from ADC), starts the reconcile loop, and serves. Public read routes (same sensitivity as plan output already on the PR, behind unguessable execution ids); /api/* is bearer-authed.

  • Live DAG. The execution renders as a group-level dependency graph: stacks fold into group nodes by their path → env/kind (configurable via the group {} block — depth or regexp), laid out in per-environment swimlanes, each node showing its stack-count, worst status, and 🔐/💣 category badges. An inert, self-contained SVG (survives GitHub's image proxy).
  • Drill-down. A folding per-stack list (grouped by the same key); each stack links to a detail page with Log / Plan / Verify tabs, live-tailed via Server-Sent Events (no polling refresh).
  • Navigation. An execution index at / (most recent first) and a per-PR timeline at /pr/{n}.
  • Approval gates keyed (class, target) — multiple approval classes, each binding a classification class to a PAM entitlement and scope. The server only ever requests a grant; humans approve in the backing provider. The GCP PAM backend uses true requester leasing (impersonates the first pool identity with no open grant, falling back to a PR mod pool slot only when exhausted).
  • GitHub checks. One check run per environment (plan/<env> — the same name as the commit-status context, so branch protection requires one consistent context). use_checks = false falls back to commit statuses (link mode).
  • Pub/Sub push ingestion (OIDC-verified) as a latency win over the poll loop.

See examples/serve.tfstackplan.hcl for the full config and Deployment below.


state — cross-stack state moves

tfstackplan state is operator-driven, declarative Terraform state-move machinery. state move writes PR-keyed shim files that the normal run apply then applies — no out-of-band terraform state surgery in the apply path.

tfstackplan state move --dir DIR [--stack STACK] [--pr N] [--via mv] <from> <to> …
tfstackplan state list    --dir DIR [--pr N]
tfstackplan state cleanup --dir DIR (--pr N | --all)
tfstackplan state apply   --dir DIR [--execute] [--lock]

state move routes each <from> <to> pair by comparing the two sides' stacks (--stack is the default for unqualified addresses; an explicit stack:addr prefix overrides). All pairs are validated against the relevant tfplan.json(s) before anything is written (fail-closed):

  • Same-stack → a native moved {} block.
  • Cross-stack → an import { to id } block in the destination shim (the id is read from the destroyed resource's before.id in the source plan) + a removed { … lifecycle { destroy = false } } block in the source shim — the resource is adopted into the new state and dropped from the old without being destroyed.
  • --via mv → instead records a _tfsp_xmove.<key>.hcl manifest in the destination stack, applied by the faithful terraform state mv executor (state apply) rather than by run apply.

Shims are keyed PR-<n> (from --pr / $TFSTACKPLAN_PR), else branch-<name>, else local. state apply discovers every _tfsp_xmove.*.hcl manifest and runs it via terraform-exec (pull → back up under .tfsp-state-backups → per-pair fail-closed decision → state mv → push, never --force). It is dry-run by default; --execute performs the moves, and --lock adds a pessimistic GCS lock. The same executor runs in the run apply cross-state-move pre-phase (always --execute, behind --state-lock).

On the projecting side, render --state-moves moves.json classifies cross-state move-targets (their planned creates) as relocations, so they don't trip the per-project IAM gate.


Configuration (.tfstackplan.hcl)

One HCL file drives all four faces; every block is optional and backward-compatible (a render-only file needs none of the server blocks).

Block Used by Purpose
classification {} render, run presets / rules / default, with emit_attributes + derive {}
diff {} render per-attribute diff defaults + overrides (detect, max_attribute_lines, rule {})
links {} render header / stack / resource URL templates
server {} run, serve url, environment
class "<name>" {} run, serve backend, entitlement, entitlement_scope, required — bind a class to an approval gate
serve {} serve the control-plane runtime (below)

The serve {} block (real field names):

serve {
  db_path            = "/data/tfstackplan.db"
  public_base_url    = "https://tfstackplan.example.com"
  use_checks         = true
  webhook_secret_env = "TFSTACKPLAN_WEBHOOK_SECRET"  # env var NAME, not the secret
  logs_dir           = "/data/logs"

  github_app {
    app_id           = "123456"
    installation_id  = "78901234"
    private_key_path = "/secrets/github-app.pem"
  }

  approval "gcp-pam" {                  # block label = backend
    location       = "global"
    duration       = "28800s"
    requester_pool = ["sa0@…", "sa1@…"]
  }

  group   { depth = 2 }                 # or: pattern = "regexp" (first capture = group key)
  objects { backend = "gcs", bucket = "tfstackplan-logs", prefix = "executions" }
  pubsub  { audience = "…/pubsub/push", service_account = "…@….gserviceaccount.com" }
}

The full, commented reference is examples/serve.tfstackplan.hcl (kept valid by a parse test).


Deployment

serve runs as a Cloud Run-class service:

  • Container — point Cloud Run at the released distroless image (ghcr.io/<org>/tfstackplan:<tag>, entrypoint serve). Single instance per environment (the SQLite store is single-writer by design; WAL + busy_timeout set at the DSN level).
  • Logs — set serve { logs_dir } for per-stack buffers and objects { … } for GCS offload of completed-stack logs (served back via a stored pointer, so viewers need no cloud IAM).
  • Pub/Sub — serve { pubsub { … } } enables OIDC-verified push ingestion as a latency win over the poll loop.
  • Credentials — Application Default Credentials supply the GCP creds for PAM (impersonation), GCS, and OIDC verification; the GitHub App key is a mounted PEM file (github_app { private_key_path }).

Deeper notes live in docs/deploy-cloud-run.md, docs/ci-integration.md, and SECURITY.md.


Out of scope

  • Posting to GitHub / GitLab / Bitbucket — render writes markdown; your CI posts it. (serve does post checks/statuses for its own check runs.)
  • Running terraform plan in render — its inputs are pre-existing plan.json files. (run drives terramate, which runs terraform.)
  • 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
approval
Package approval defines the approval-gate backend abstraction.
Package approval defines the approval-gate backend abstraction.
approval/gcppam
Package gcppam implements the approval.Backend over GCP Privileged Access Manager (PAM).
Package gcppam implements the approval.Backend over GCP Privileged Access Manager (PAM).
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.
jwtutil
Package jwtutil provides minimal HS256 JWT creation and validation for internal auth (API bearer tokens and view-link tokens).
Package jwtutil provides minimal HS256 JWT creation and validation for internal auth (API bearer tokens and view-link tokens).
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.
reconcile
Package reconcile is the pure functional core for the server's gate/grant and execution-lifecycle state.
Package reconcile is the pure functional core for the server's gate/grant and execution-lifecycle state.
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.
runner
Package runner is the CI-side driver: it invokes the same terramate scripts a human runs, captures per-stack output, renders + classifies in-process, and reports progress to the control-plane server over the typed events protocol.
Package runner is the CI-side driver: it invokes the same terramate scripts a human runs, captures per-stack output, renders + classifies in-process, and reports progress to the control-plane server over the typed events protocol.
server
Package server is the control-plane HTTP service: it records multi-stack Terraform executions, drives one GitHub check run per environment, and projects approval-gate state — all as a pure function of its SQLite store.
Package server is the control-plane HTTP service: it records multi-stack Terraform executions, drives one GitHub check run per environment, and projects approval-gate state — all as a pure function of its SQLite store.
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.
statemove
Package statemove validates declared Terraform resource moves against plan data and generates/maintains the native `moved {}` shim files that effect them (SP1: within one stack).
Package statemove validates declared Terraform resource moves against plan data and generates/maintains the native `moved {}` shim files that effect them (SP1: within one stack).
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