terrakit

terrakit

Docs

Everything the shipped binary accepts and everything it prints. This page documents v0.2.1, and it documents what exists rather than what is planned.

Install

With a Go toolchain

go install github.com/dbhq-uk/terrakit/cmd/terrakit@latest

Or download a binary from the releases page (external site). A release ships terrakit and tkit, built from the same package, so use whichever name is free on your machine. Builds are for Linux and macOS, on amd64 and arm64; there is no native Windows build, so on Windows it wants WSL.

Use

terrakit reads a plan that Terraform or OpenTofu has already produced:

terraform plan -out tfplan
terraform show -json tfplan > plan.json
terrakit plan.json

Or read it from standard input and never write the plan to disk at all. This is the better habit: plan JSON can hold a credential in the clear, and a file on a shared runner is one more place for one to be left.

terraform show -json tfplan | terrakit -

Flags go before the file. Go's flag parsing stops at the first positional argument, so a flag written after the filename is not read. terrakit notices that case and tells you what to type instead of exiting on a bare usage block.

Flags

FlagWhat it does
--format terminal|md|json|htmlOutput format. Default terminal. Any other value is an error rather than a fallback.
--out <path>Write the report to a file instead of standard output, and print one line naming it. Works for every format. The file is created mode 0600 and never contains colour.
--fail-on critical|high|low|infoExit 1 if any finding reaches this level. Off by default, and measured against every finding rather than against what was displayed.
--min-level critical|high|low|infoOnly show findings at this level or above. The counts stay complete and the report says how many were held back. It changes what you read, never what was found and never the exit code.
--plainNo colour, and ASCII only: no box drawing anywhere in the output. For a pipeline, a log viewer, or a console that renders box characters badly.
--no-colour, --no-colorNever colour terminal output. The British spelling is canonical and the American one is an alias, so guessing wrong does not cost you a run.
--versionPrint the version and exit, without needing a plan.

--min-level and --fail-on are independent on purpose. Filtering changes what you read, never what was found: the counts stay complete, the report says how many findings were held back and what the bar was, and the gate is measured against every finding. Turning the volume down cannot turn a gate off.

terrakit --min-level high plan.json
terrakit  5 findings  terraform 1.16.1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

CRITICAL ──────────────────────────────────────────────────────────────────  1

  azurerm_postgresql_flexible_server.main
  destroy and create
  ├ holds data, so destroying it loses that data
  ├ an attribute changed that cannot be updated in place
  ├ forces replacement   zone
  ├ these values are not known until apply, so no claim about them can be
  │ checked in review
  │   fqdn
  │   id
  └ these values are sensitive and are redacted in all output
      administrator_password

HIGH ──────────────────────────────────────────────────────────────────────  1

  azurerm_subnet.app
  destroy
  ├ its configuration block was removed
  └ possible missed moved block
      6 of 6 attributes match azurerm_subnet.application
      moved { from = azurerm_subnet.app  to = azurerm_subnet.application }
      verify the pairing before using that block

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1 critical  1 high  1 low  2 info                       3 below high not shown

Levels

Four, and deliberately no medium: a middle bucket is where findings go to be ignored.

LevelWhat reaches it
criticalA destroy or a replacement of a resource type that holds data, so destroying it loses that data. This is the only escalation in the tool, and it applies to destruction alone: updating a database in place does not lose data.
highAny other destroy, or any replacement. The resource goes away and comes back, whatever is or is not inside it.
lowAn update in place, or a resource that is being forgotten from state but left running.
infoA create, a data source read, an import and a no-op. Nothing existing is being taken away.

A resource type terrakit has not been curated against is never assumed safe. A destroy of an unrecognised type keeps its base level and is annotated to say that whether destroying it loses data has not been assessed.

Output formats

terminal

The default. One section per severity present, most severe first, and none at all for a severity with nothing in it. The report is set to the width of the stream it is going to, clamped between 60 and 100 columns, so a report redirected to a file is not set to the width of whatever terminal launched it. --plain drops colour and every box-drawing character, for a pipeline or a console that renders them badly.

md

A table for a pull request comment or a job summary, with the evidence behind each annotation in a collapsed section underneath, including the suggested moved block where there is one.

terrakit --format md plan.json
| Level | Change | Resource | Notes |
|---|---|---|---|
| CRITICAL | destroy and create | `azurerm_postgresql_flexible_server.main` | holds data, so destroying it loses that data; an attribute changed that cannot be updated in place; forces replacement: `zone` |

1 finding.

json

Indented JSON for machines. The shape is below.

html

One self-contained document: inline CSS, no external stylesheet, no font, no image and no script. It opens from a file:// URL with nothing else on disk and follows prefers-color-scheme, so it reads the same way in a dark editor as in a light browser. Anything taken from the plan is escaped before it is written, because a resource address can carry a key somebody else chose.

--out writes any format to a path and prints one line naming it, so nothing else lands on standard output. The file is created mode 0600: the report names every resource in the plan, which on a shared runner is a map of the estate. Colour is never written to a file.

The JSON shape

terrakit --format json plan.json
{
  "terraform_version": "1.9.8",
  "format_version": "1.2",
  "findings": [
    {
      "address": "azurerm_postgresql_flexible_server.main",
      "type": "azurerm_postgresql_flexible_server",
      "provider": "registry.terraform.io/hashicorp/azurerm",
      "kind": "replace",
      "level": "critical",
      "reason": "an attribute changed that cannot be updated in place",
      "replace_paths": [
        "zone"
      ],
      "data_loss": true
    }
  ],
  "counts": {
    "critical": 1
  }
}

Four things about it that are easy to get wrong:

  • findings is always an array, never null, even on a clean plan. A nil slice would marshal as null and break iteration on the one plan whose answer is good news.
  • counts always describes the whole plan, even under --min-level. It is not recounted around the filter.
  • hidden and hidden_below are omitted entirely on an unfiltered run, so read .hidden // 0 rather than assuming the key exists.
  • reason, replace_paths, module, provider and annotations are omitted when empty rather than emitted as nulls.

A possible-missed-moved-block annotation carries a moved object beside its sentence, holding the two addresses, the matched and compared attribute counts and whether the pairing crossed a module boundary. It is there so a consumer that cannot print a paragraph is not left with a bare code.

Annotation codes

An annotation is extra context attached to a finding. Annotations are reported separately from the level rather than folded into it, so their reasoning is always visible.

CodeWhat it means
possible-missed-moved-blockA destroy and a create that look like one resource renamed without a moved block. It carries the two addresses, the matched and compared attribute counts, and whether the pairing crossed a module boundary.
unverifiable-until-applyAttributes Terraform cannot know until apply, named by path. No claim about them can be checked in review, and saying so is the feature.
sensitiveAttributes Terraform marked sensitive, named by path. The values are not printed, and neither is anything else.
unrecognised-providerA resource being destroyed whose provider is not one terrakit has been curated against, so whether destroying it loses data has not been assessed. An unrecognised type is never assumed safe.
same-elements-reorderedA changed list holds the same elements in a different order. A fact, not a verdict: order is significant for a container command or an ordered rule list, so the call is yours.
same-json-written-differentlyBoth sides parse as JSON and hold the same data with the keys in a different order. Policy documents and anything a provider round-trips as a JSON blob.
same-text-different-whitespaceBoth sides are the same text laid out differently: a trailing newline, an indent, a CRLF against an LF.
same-number-written-differentlyBoth sides are the same number written another way, such as 80 against the string form of 80.
null-on-one-side-empty-on-the-otherOne side is null and the other is an empty list, object or string.
every-changed-attribute-written-differentlyThe roll-up: every attribute the plan shows as changed on this resource fell into one of the classes above. It is a strict claim, so one real change anywhere on the resource silences it.

None of the rewrite classes moves a finding's level, and none of them says a change is harmless. Each one has a case where the difference is real: order is significant for a container command, whitespace is significant in a script and in anything hashed, a number and its string form are different types, and null and empty are not the same thing to Terraform everywhere. terrakit names the kind of difference and leaves the ruling to you.

terrakit plan.json
terrakit  5 findings  terraform 1.9.8
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

LOW ───────────────────────────────────────────────────────────────────────  5

  aws_iam_policy.pipeline
  update in place
  ├ same JSON, written differently
  │   policy
  └ every attribute this plan shows as changed here is a difference in
    how the value is written

  aws_security_group.web
  update in place
  ├ same elements, different order
  │   ingress[0].cidr_blocks
  ├ same number, written differently
  │   ingress[0].from_port
  │   ingress[0].to_port
  └ every attribute this plan shows as changed here is a difference in
    how the value is written

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5 low

Exit codes

CodeWhen
0The report was written. This is the answer whenever --fail-on is not set, whatever the plan contains.
1--fail-on was set and a finding reached that level, measured against every finding rather than the displayed ones.
2Something was wrong with the invocation or the input: an unknown flag or format, an unknown level name, no file argument, a plan that would not load, or a write that failed.

The separation matters in a pipeline. 1 means the tool worked and the plan failed your gate; 2 means the tool did not work, and treating the two as one number is how a broken step gets read as a passing one.

Colour

Colour is emitted only when the output is going to a terminal, so a pipe and a file both come out plain. Beyond that:

  • NO_COLOR (external site) set to anything non-empty switches colour off, whatever its value. An empty value means nothing and does not opt in.
  • FORCE_COLOR turns it back on when the destination is a pipe, which a CI log that renders ANSI and a pager held open with less -R both need.
  • NO_COLOR wins if both are set, because turning colour off should never be the setting that loses.
  • --no-colour and its American alias --no-color both work. --plain goes further and drops the box drawing too.

In CI

terrakit --format md plan.json >> "$GITHUB_STEP_SUMMARY"
terrakit --fail-on critical plan.json

--fail-on is off by default, because a tool that blocks by default gets switched off on day one rather than adopted. Adopt it read-only first, see what it says about the plans you are already approving, and turn the gate on afterwards.

There is a GitHub Action in the repository that does both in one step, and one step is the point: the same run produces the summary and the exit code, so the two cannot disagree and a large plan is not assessed twice.

GitHub Actions
- uses: dbhq-uk/terrakit@v0.2.1
  with:
    plan: plan.json
    fail-on: critical
InputWhat it does
planPath to the plan JSON, or - to read it from standard input. Required.
fail-onExit 1 if any finding reaches this level. Empty by default, which is no gate at all.
summaryAppend the markdown report to the job summary. True by default.
versionWhich release to install. Defaults to the ref the action was pinned at, so pinning the action pins the binary and the two cannot disagree.

Pin the action to a tag. It never installs from @latest, and a released action must not change what it runs because somebody pushed to a branch.

Three things it will not do

  • It takes a file, and runs nothing

    terrakit reads a plan file, or the same JSON piped in on standard input. It never runs terraform, never reads a cloud credential, never makes a network call and never applies anything. The only file it writes is the one you name with --out, created mode 0600 because the report lists every resource in the plan.

  • It never prints an attribute's value, in any format

    Not masked, not redacted, not truncated. Values are not in the output at all. Masking depends on Terraform having marked a value sensitive, and that marking is best-effort: a live credential was found in a real plan that Terraform had not marked. Paths, counts, levels and terrakit's own sentences are all it will ever show you.

  • It is deterministic, with no model in the loop

    The same plan always produces the same verdict. Nothing is sent anywhere, there is no model to talk you round, and no ranking that cannot be read straight off the plan. Findings are sorted most severe first with ties broken on the resource address, so two runs of the same plan are byte for byte identical.

And what it is not for

terrakit does not model consequences, validate against provider schemas, check policy or estimate cost. Other tools do those, and it is better to be plain about the line than to imply a wider one. It also never writes to your configuration: a tool that generates the moved blocks for you is a different tool for a different moment, useful to the person doing the rename rather than the person reviewing it.

terrakit uses Google Analytics to count how many people read these pages. No cookie is set unless you accept, and every page works exactly the same either way.