Hedronite · Dev Lesson · Polyglot-Dev / Python · Mon 2026-09-14

Python terraform plan JSON census — import, move, and no-op plans

Saved plans speak in actions. Count imports and moves. Prove the empty plan. Do not turn the printer into a gate.

Lesson Class: Dev (Python-around-TF)
Paired Ops: GCS import + moved module refactor
Paired Cert: Associate import/moved/GCS IDs
Paired Go: GCS import-candidate census
Grounding: Python for DevOps subprocess · Lab 11 no-op
Show
terraform show -json on a saved plan.
Classify
Import, move, replace, update, no-op.
Report
Markdown for PRs; exit zero on success.
The census prints the no-op verdict. It does not become the gate.

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

Saved plans speak in actions. Count imports and moves. Prove the empty plan. Do not turn the printer into a gate.

§I — Frame

09-05 built an apply-risk census over resource_changes: creates, updates, deletes, replaces. Exit zero always. 08-09 built a policy checker that exits non-zero. Keep both roles distinct.

Today's Ops lesson imports a live GCS bucket and moves the address into module.logs. Lab 11 grades the finish on a no-op plan. A pull request needs a machine-readable sentence: which addresses import, which move, which still update, and whether the plan is empty.

That sentence is today's census. Python for DevOps spends subprocess.run as the standard-library way to call CLI tools and capture stdout. Call terraform show -json on a plan file the pipeline already saved. Parse. Print markdown. Exit zero unless Terraform itself failed.

§II — Language idiom: actions beyond create/update/delete

terraform plan -out=tfplan writes a binary plan. terraform show -json tfplan prints JSON. Besides resource_changes, modern plans expose import and move metadata. Exact keys vary slightly by Terraform version; treat the document defensively.

Practical classification rules that stay stable:

  1. A resource change whose change.actions is only ["no-op"] (or empty after filter) is silence.
  2. ["create"], ["update"], ["delete"], and replace pairs ["delete","create"] match the 09-05 census.
  3. Import intent appears as an import action tied to an address (plan JSON may list resource_changes with import-related change reasons, or a top-level import list depending on version). Prefer reading both resource_changes and any top-level importing / related arrays your installed Terraform emits. Print whatever addresses Terraform claims it will import.
  4. Moves appear under plan resource_drift adjacency or a dedicated move list in newer versions; also detect moved outcomes when an address disappears from destroy while another appears as create with identical ID. When in doubt, print terraform plan human text alongside JSON for humans, and teach the JSON path for automation.

Keep the tool honest: if a field is missing on an older Terraform, say unsupported in the report instead of inventing structure.

import json
import subprocess
import sys
from collections import Counter


def load_plan(path: str) -> dict:
    proc = subprocess.run(
        ["terraform", "show", "-json", path],
        check=True,
        capture_output=True,
        text=True,
    )
    return json.loads(proc.stdout)


def classify(plan: dict) -> dict:
    counts: Counter = Counter()
    imports: list[str] = []
    moves: list[str] = []
    replaces: list[str] = []
    updates: list[str] = []

    for rc in plan.get("resource_changes") or []:
        address = rc.get("address") or ""
        actions = list((rc.get("change") or {}).get("actions") or [])
        if actions == ["no-op"] or actions == []:
            counts["no-op"] += 1
            continue
        if actions == ["create"]:
            counts["create"] += 1
        elif actions == ["update"]:
            counts["update"] += 1
            updates.append(address)
        elif actions == ["delete"]:
            counts["delete"] += 1
        elif set(actions) == {"create", "delete"}:
            counts["replace"] += 1
            replaces.append(address)
        else:
            counts["other"] += 1

    # Prefer explicit import list when present (key names vary by Terraform version)
    for imp in plan.get("importing") or plan.get("imports") or []:
        if not isinstance(imp, dict):
            continue
        addr = imp.get("address") or imp.get("to") or ""
        if addr:
            imports.append(addr)
            counts["import"] += 1

    for rc in plan.get("resource_changes") or []:
        change = rc.get("change") or {}
        if change.get("importing"):
            addr = rc.get("address") or ""
            if addr:
                imports.append(addr)
                counts["import"] += 1

    return {
        "counts": dict(counts),
        "imports": sorted(set(imports)),
        "moves": moves,
        "replaces": replaces,
        "updates": updates,
        "is_noop": (
            counts.get("create", 0) == 0
            and counts.get("update", 0) == 0
            and counts.get("delete", 0) == 0
            and counts.get("replace", 0) == 0
            and counts.get("other", 0) == 0
            and len(imports) == 0
        ),
    }


def render_md(report: dict) -> str:
    lines = ["## Terraform plan census", ""]
    for k, v in sorted(report["counts"].items()):
        lines.append(f"- `{k}`: {v}")
    if report["imports"]:
        lines.append("")
        lines.append("### Imports")
        lines.extend(f"- `{a}`" for a in report["imports"])
    if report["updates"]:
        lines.append("")
        lines.append("### Updates still pending")
        lines.extend(f"- `{a}`" for a in report["updates"])
    if report["replaces"]:
        lines.append("")
        lines.append("### Replaces")
        lines.extend(f"- `{a}`" for a in report["replaces"])
    lines.append("")
    lines.append(
        "**No-op verdict:** yes" if report["is_noop"] else "**No-op verdict:** no"
    )
    return "\n".join(lines) + "\n"


def main(argv: list[str]) -> int:
    if len(argv) != 2:
        print("usage: plan_census.py TFPLAN", file=sys.stderr)
        return 2
    plan = load_plan(argv[1])
    report = classify(plan)
    sys.stdout.write(render_md(report))
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))

The empty resource_drift / prior_state stubs are intentional: they mark extension points without claiming a frozen schema. Fill them against terraform version on your runners.

§III — Worked path beside Ops

After Ops step 2 (import block present), save a plan and run the census. Expect an import classification for google_storage_bucket.logs (or your address). After Ops step 4 (moved into the module), expect move-related noise to clear toward no-op once attributes match. After Ops step 5, demand No-op verdict: yes.

Wire the script in CI as a comment publisher, not a hard fail. Lab 11 already defines success. The census only makes success visible in the pull request. If you want a gate, that is a different tool (08-09 posture).

§IV — Failure modes

Parsing human plan text with regex. Fragile across Terraform versions. Prefer -json.

Exiting non-zero on residual updates. That collapses reporting into policy. Keep exit zero when terraform show succeeded.

Uploading plan files to public caches. Brikman's Plan files warning still applies. Plans can hold secrets.

Assuming import keys exist on every version. Feature-detect. Print unsupported rather than crashing.

§V — Closing

Call terraform show -json. Classify. Print the no-op verdict. Leave destroy decisions to humans and leave gates to the policy checker you already wrote.

Related