terraken

Terraken

Docs

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

Install

With a Go toolchain

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

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

Use

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

terraform plan -out tfplan
terraform show -json tfplan > plan.json
terraken 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 | terraken -

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. Terraken 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.

terraken --min-level high plan.json
terraken  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 Terraken 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.

terraken --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

terraken --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 Terraken 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. Terraken names the kind of difference and leaves the ruling to you.

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

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

  aws_ecs_task_definition.api
  update in place
same text, different whitespace
container_definitions
every attribute this plan shows as changed here is a difference in how the
    value is written

  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_instance.bastion
  update in place
same text, different whitespace
user_data
same JSON, written differently
      metadata

  aws_lb_target_group.app
  update in place
null on one side, empty on the other
load_balancing_anomaly_mitigation
tags
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

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Whitespace is significant in a script, in a YAML document carried as a string
and in anything hashed, so whether a whitespace change matters is yours to
judge.

Each of these classes has a case where the difference is real. Order matters
in a container command, and whitespace matters in a script. This says what
kind of difference each one is and rules on none of them.

A consumer that compares the document byte for byte still sees a change, so
whether a rewritten document matters is yours to judge.

Null and empty are not the same thing to Terraform everywhere, where null can
mean inherit a default and empty means explicitly none, so whether this one
matters is yours to judge.

Order is significant for some attributes, such as a container command or an
ordered rule list, so whether a reordering matters is yours to judge.

A number and its string form are different types, and a type change can
matter, so whether this one does is yours to judge.

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

terraken --format md plan.json >> "$GITHUB_STEP_SUMMARY"
terraken --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/terraken@v0.3.0
  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

    Terraken 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 Terraken'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

Terraken 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.

Terraken 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.