Terraform configuration
The Terraform configuration in the checkout: every root directory holding .tf files, the backend it stores state in, the providers and modules it takes on and how tightly they are pinned, the resources it declares, the variables it takes, the lock file that resolves it, and any state file left in the tree. Everything is read from the files themselves, so it works on a runner with no Terraform installed. When the terraform CLI is there, the versions an initialised root has already resolved are added.
| Facts key | terraform |
| Version | v1 |
| Script | terraform.py |
| Timeout | 90 seconds |
Tools
This collector reads what these tools report, but it never installs them. If a tool isn't on the runner, the evidence is missing and the guardrails that need these facts skip instead of failing.
| Tool | Needed | Description |
|---|---|---|
terraform | optional | Terraform CLI, run only to read back the provider versions an already initialised root resolved. Never init, plan or apply, so nothing it does reaches real infrastructure. |
Installing terraform
Install with brew install terraform, or add the hashicorp/setup-terraform step to the workflow. The configuration is read out of the files either way, so this only adds the resolved versions.
Inputs
| Input | Description | Default | Environment |
|---|---|---|---|
directory | Directory the configuration is looked for under, relative to the directory the CLI runs in. By default discovery walks the whole working directory; this only narrows it, so a repository that keeps its Terraform in one place collects just that place. | . | GUARDRAIL_INPUT_DIRECTORY |
When a guardrail declares an input with the same name, it passes its value through. That means you set these values in the guardrail's configuration in buildnote.json.
A guardrail asks for these facts by name and reads them back by the same name:
{
"collect": ["terraform"]
}terraform = guardrail.facts("terraform")Facts
These are the fields of the document terraform collects. In a path, [] means each entry of the list before it, and [path] means a key of the object before it.
| Fact | Meaning |
|---|---|
directory | The directory set by the guardrail's directory input, relative to the directory the CLI runs in. Every other path this collector reports is also relative to the directory the CLI runs in, so it resolves from where you invoked the CLI, not from wherever the collector happened to run. |
source | report when the configuration was read out of the checkout, tool when terraform was also run against an initialised root, none when there is no Terraform here at all. |
reason | Why nothing was found, present only when source is none. |
reports | Every configuration file that was read and understood, in path order. A file the reader could not handle is in unparsed instead. |
reports[].path | Path of the file, relative to the directory the CLI runs in. |
reports[].modified | When the file was last written, ISO 8601. |
reports[].format | What it was read as: tf, tfvars or lock. |
tool.name | terraform, present only when it was run. |
tool.version | Version of the terraform on the runner. |
tool.path | Where that terraform was found on PATH. |
tool.args | Arguments of the last invocation. providers -json is tried first and version -json is the fallback, because only the second is supported by every release. |
tool.exitCode | What that invocation exited with. |
tool.durationMs | How long it took. |
roots | Every directory holding at least one .tf file, in path order. A reusable module directory counts as one too, so keep that in mind when reading backend.type on a root that is not live. |
roots[].path | Directory of the root, relative to the directory the CLI runs in. |
roots[].backend.type | Backend the root stores state in, as the backend label names it. local when the root declares no backend block at all, which is itself a finding, not a default to ignore. |
roots[].backend.attributes | Every attribute the backend block declares, by name. A value is carried only when it is a plain scalar the reader could resolve; anything interpolated, structured or named like a credential is null, so the key shows the attribute is set without the secret becoming evidence too. |
roots[].backend.encrypted | Whether the backend declares its state encrypted, from encrypt or a key attribute for the backends that have one. null when the backend says nothing either way, which is not the same as false. |
roots[].backend.locking | Whether the backend declares state locking, from use_lockfile, dynamodb_table, lock or lock_address depending on the backend. null when the backend says nothing either way. |
roots[].providers | Every provider the root requires, from required_providers first and then any provider block naming one that was not required. |
roots[].providers[].name | Local name of the provider, as the configuration refers to it. |
roots[].providers[].source | Source address as written, such as hashicorp/aws, or null when only a provider block names it. |
roots[].providers[].version | Version constraint exactly as written, such as ~> 6.0, or null when none is declared. |
roots[].providers[].pinned | Whether that constraint pins one exact version. A ~>, a range, or no constraint at all is not pinned. |
roots[].modules | Every module block the root declares, in the order the files declare them. |
roots[].modules[].name | Label of the module block. |
roots[].modules[].source | Its source exactly as written, or null when the source is interpolated rather than literal. |
roots[].modules[].version | Its version constraint, which only a registry source can carry, or null. |
roots[].modules[].local | Whether the source is a relative path inside the repository, so nothing is fetched. |
roots[].modules[].registry | Whether the source is a module registry address, meaning version is what pins it. |
roots[].modules[].git | Whether the source is a git or HTTP URL, meaning the ref in the URL is what pins it and version never applies. |
roots[].resources | How many resource blocks the root declares, by resource type. data blocks are not counted. |
roots[].resourceTypes | Those resource types, sorted. |
roots[].variables | Every variable block the root declares. Names only: a default can be a credential and these facts are attached to the build as evidence. |
roots[].variables[].name | Label of the variable block. |
roots[].variables[].sensitive | Whether it declares sensitive = true. |
roots[].variables[].hasDefault | Whether it declares a default at all. The default itself is never collected. |
roots[].varFiles | Every .tfvars file committed beside the root. |
roots[].varFiles[].path | Path of the file, relative to the directory the CLI runs in. |
roots[].varFiles[].variables | The variable names it assigns, sorted. The values are never collected. |
roots[].lockfile.present | Whether the root commits a .terraform.lock.hcl. |
roots[].lockfile.path | Path of that lock file, relative to the directory the CLI runs in, or null when there is none. |
roots[].lockfile.providers | Every provider the lock file resolves. |
roots[].lockfile.providers[].name | Last segment of the provider address, matching the local name in the usual case. |
roots[].lockfile.providers[].source | Full address the lock file names, such as registry.terraform.io/hashicorp/aws, so a mirror is recognisable. |
roots[].lockfile.providers[].version | Version it resolves to. |
roots[].lockfile.providers[].hashes | How many hashes it records. Only the count is kept, not the hashes, because the count is what shows whether the lock covers more than one platform. |
roots[].unparsed | How many files in this root the reader could not handle. Above zero, this root is described from only part of its configuration, so treat an absence here as unknown, not as a finding. |
stateFiles | Every .tfstate or .tfstate.backup file left in the tree, which is a finding in itself. The walk skips .terraform, so a working directory Terraform manages is not one of these. |
stateFiles[].path | Path of the state file, relative to the directory the CLI runs in. |
stateFiles[].bytes | Its size in bytes. The contents are never read. |
unparsed | Every file the reader could not handle, so a guardrail can tell a configuration that says nothing apart from one that could not be read. |
unparsed[].path | Path of that file, relative to the directory the CLI runs in. |
unparsed[].reason | Why the reader could not handle it. |
resolved | Provider versions an initialised root has already resolved, read back with terraform. Present only when the tool is on the runner and the root carries a .terraform/providers directory, which is what terraform init leaves behind and what tells an initialised root from a stale one. |
resolved[].root | Root the versions were read in. |
resolved[].name | Last segment of the provider address. |
resolved[].source | Full provider address as Terraform names it. |
resolved[].version | Version Terraform selected. |
If a collector can't finish, it prints what it gathered so far along with an incomplete key that says why. Facts after the point where it stopped are missing, so a check that depends on them should read incomplete first.
Example facts
Here are the facts the terraform collector gathers from an example project:
{
"directory": ".",
"source": "report",
"reports": [
{
"path": ".terraform.lock.hcl",
"modified": "2026-03-04T10:15:00Z",
"format": "lock"
},
{
"path": "main.tf",
"modified": "2026-03-04T10:15:00Z",
"format": "tf"
}
],
"roots": [
{
"path": ".",
"backend": {
"type": "s3",
"attributes": {
"bucket": "widget-terraform-state",
"key": "live/widget.tfstate",
"region": "eu-west-1",
"encrypt": true,
"use_lockfile": true
},
"encrypted": true,
"locking": true
},
"providers": [
{
"name": "aws",
"source": "hashicorp/aws",
"version": "6.4.0",
"pinned": true
},
{
"name": "random",
"source": "hashicorp/random",
"version": "~> 3.6",
"pinned": false
}
],
"modules": [
{
"name": "events",
"source": "terraform-aws-modules/sqs/aws",
"version": "4.2.1",
"local": false,
"registry": true,
"git": false
}
],
"resources": {
"aws_s3_bucket": 1,
"aws_s3_bucket_versioning": 1
},
"resourceTypes": [
"aws_s3_bucket",
"aws_s3_bucket_versioning"
],
"variables": [
{
"name": "region",
"sensitive": false,
"hasDefault": true
},
{
"name": "api_token",
"sensitive": true,
"hasDefault": false
}
],
"varFiles": [],
"lockfile": {
"present": true,
"path": ".terraform.lock.hcl",
"providers": [
{
"name": "aws",
"source": "registry.terraform.io/hashicorp/aws",
"version": "6.4.0",
"hashes": 3
},
{
"name": "random",
"source": "registry.terraform.io/hashicorp/random",
"version": "3.6.3",
"hashes": 2
}
]
},
"unparsed": 0
}
],
"stateFiles": [],
"unparsed": []
}