Install
With a Go toolchain
go install github.com/dbhq-uk/terrakit/cmd/terrakit@latestOr 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 tfplanterraform show -json tfplan > plan.jsonterrakit plan.jsonOr 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
| Flag | What it does |
|---|---|
--format terminal|md|json|html | Output 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|info | Exit 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|info | Only 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. |
--plain | No 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-color | Never colour terminal output. The British spelling is canonical and the American one is an alias, so guessing wrong does not cost you a run. |
--version | Print 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 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 shownLevels
Four, and deliberately no medium: a middle bucket is where findings go to be ignored.
| Level | What reaches it |
|---|---|
critical | A 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. |
high | Any other destroy, or any replacement. The resource goes away and comes back, whatever is or is not inside it. |
low | An update in place, or a resource that is being forgotten from state but left running. |
info | A 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.
| 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
{
"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:
findingsis 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.countsalways describes the whole plan, even under--min-level. It is not recounted around the filter.hiddenandhidden_beloware omitted entirely on an unfiltered run, so read.hidden // 0rather than assuming the key exists.reason,replace_paths,module,providerandannotationsare 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.
| Code | What it means |
|---|---|
possible-missed-moved-block | A 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-apply | Attributes Terraform cannot know until apply, named by path. No claim about them can be checked in review, and saying so is the feature. |
sensitive | Attributes Terraform marked sensitive, named by path. The values are not printed, and neither is anything else. |
unrecognised-provider | A 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-reordered | A 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-differently | Both 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-whitespace | Both sides are the same text laid out differently: a trailing newline, an indent, a CRLF against an LF. |
same-number-written-differently | Both sides are the same number written another way, such as 80 against the string form of 80. |
null-on-one-side-empty-on-the-other | One side is null and the other is an empty list, object or string. |
every-changed-attribute-written-differently | The 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 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 lowExit codes
| Code | When |
|---|---|
0 | The 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. |
2 | Something 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_COLORturns it back on when the destination is a pipe, which a CI log that renders ANSI and a pager held open withless -Rboth need.NO_COLORwins if both are set, because turning colour off should never be the setting that loses.--no-colourand its American alias--no-colorboth work.--plaingoes 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.
- uses: dbhq-uk/terrakit@v0.2.1
with:
plan: plan.json
fail-on: critical| Input | What it does |
|---|---|
plan | Path to the plan JSON, or - to read it from standard input. Required. |
fail-on | Exit 1 if any finding reaches this level. Empty by default, which is no gate at all. |
summary | Append the markdown report to the job summary. True by default. |
version | Which 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.