Skip to content

Getting Started

llms.txt lists every page for an agent
Optional: hand this page to your coding agentThe steps work by hand too.
Show the whole prompt
Read https://intentius.io/chant/lexicons/terraform/getting-started/.
Install `@intentius/chant-lexicon-terraform`, and name this repository's Terraform root modules under `terraform.roots` in `chant.config.ts`, with `binary` set to the tool the repository uses.
Run `npx chant build` and `npx chant graph --format ir`, and report what the post-synth checks find in each root.
Do not run `terraform apply` or `tofu apply`, and do not declare a `TerraformApplyOp`. Open a pull request with the config.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.

Most chant lexicons ask you to declare infrastructure in TypeScript. This one starts from the .tf files you already have. You name the root modules, chant parses them, and the blocks join the build as entities that the post-synth checks can read. Nothing is written back over the HCL.

Terminal window
npm install --save-dev @intentius/chant-lexicon-terraform

Add the terraform namespace to chant.config.ts. Importing the package is what brings the key into ChantConfig.

import type { ChantConfig } from "@intentius/chant/config";
import "@intentius/chant-lexicon-terraform";
export default {
lexicons: ["terraform"],
terraform: {
binary: "terraform",
roots: {
app: { dir: "./terraform" },
},
},
} satisfies ChantConfig;

dir resolves against the project root, the directory holding chant.config.ts, because a .tf tree usually sits beside the typed source rather than inside it. Each root also takes an optional workspace, a varFiles list and a backendConfig map.

binary is "terraform" or "tofu". The two are wire-compatible for everything this lexicon does, so the choice is recorded rather than guessed.

Terminal window
chant build

Every block of every configured root becomes one entity, keyed <root>/<address>:

HCLEntity typeKey
terraform { ... }Terraform::Terraformapp/terraform
provider "null" {}Terraform::Providerapp/provider.null
resource "null_resource" "web" {}Terraform::Resourceapp/null_resource.web
data "aws_ami" "base" {}Terraform::Dataapp/data.aws_ami.base
module "cdn" {}Terraform::Moduleapp/module.cdn
variable "region" {}Terraform::Variableapp/var.region
output "url" {}Terraform::Outputapp/output.url
locals { ... }Terraform::Localsapp/locals

Each entity carries props.address, props.body (the block, verbatim from the parser), props.file and props.root.

The build writes no Terraform of its own. terraform apply keeps working on your .tf files as before.

Terminal window
chant graph --format ir

Every parsed block is a node, and every reference between two blocks is an edge. Five reference forms become edges: resource, data, module, var and local. depends_on is one too, with no attribute on it.

Each edge names the attribute it flows through on both sides, the way chant graph labels any other reference. A subnet_id = aws_subnet.private.id gives viaAttr: "subnet_id" and toAttr: "id"; an edge whose reference named two producer attributes carries no toAttr rather than an arbitrary one.

A count or for_each block stays one node. chant’s entity is the block, not the instance, so count = 3 draws one card and one edge per target, and the block records which meta-argument expanded it on props.expansion. The instance count is known only at plan time, and this reads the declaration.

References resolve inside their own module scope, which is Terraform’s own namespace: a var.region inside module.cdn is the child module’s variable and never the root’s. Nothing resolves across roots. Separate roots read each other by name through a data source, not by reference.

groups.byStack is keyed by root name, one entry per root you named in terraform.roots. That is the grouping a boundary-box renderer draws, and a root is what one terraform apply runs against. groups.byLexicon keeps saying terraform.

A provider = aws.replica meta-argument is not an edge. It names a provider block by type and alias together, and the entity key for one is provider.<type> with the alias inside the body, so two aliased providers of the same type cannot be told apart from the reference.

TF001 fires once per root whose terraform block declares neither a backend "<type>" nor a cloud {}, and once per root whose only backend is backend "local". Such a root keeps its state in a local terraform.tfstate: unshared, unlocked, and holding every resource attribute in plaintext. The first apply from a second machine starts from an empty state and proposes to create the estate again.

Add a remote backend to clear it:

terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "app/terraform.tfstate"
region = "us-east-1"
}
}

A root whose dir does not exist, or one holding a .tf the parser refuses, produces a warning naming the root and contributes no entities. The rest of the build carries on, and the other roots are unaffected. Reading someone else’s estate is the job here, so half of it parsing is more useful than none of it.

Driving the root: TerraformApplyOp on the local executor

Section titled “Driving the root: TerraformApplyOp on the local executor”

The build only reads the root’s blocks. To run init, plan and apply on it, declare a TerraformApplyOp and run it with chant run:

import { TerraformApplyOp } from "@intentius/chant-lexicon-terraform";
export const { op } = TerraformApplyOp({
name: "app-apply",
root: "app",
gate: "never", // no approval step, so a run goes straight through, see below
});

gate: "never" matters here specifically: every other gate mode emits a Gate phase, and a run that reaches a gate nobody has approved records a pending fact on the gate ledger, ends with status gated and exits 3, so Apply never runs until someone records the resolution with chant approve. With it, chant run app-apply executes Init, Plan and Apply straight through in your terminal. See Ops for every TerraformApplyOp option, the four activities and builders underneath it, and the compensation rule for a failed apply.

A root can also be a member of a chant workspace without a chant project of its own. This lexicon supplies the terraform member kind as data, in workspace-kinds.json at its ./workspace-kinds subpath. Pin the lexicon in chant.workspace.json and declare the root with that kind.

{
"name": "estate",
"schema": 1,
"pins": [{ "package": "@intentius/chant-lexicon-terraform", "version": "0.80.0" }],
"members": [{ "name": "network", "dir": "network", "kind": "terraform" }]
}

The kind’s probe passes when a .tf file sits directly in the member’s directory, and its precedence is 400. A chant project (500) that keeps .tf files beside its config is still a chant member. A choudoufu root passes this probe too, and the choudoufu kind this lexicon also ships (450, an estate.chdf.hcl sidecar or a live block) outranks it. chant workspace ls reads the kind from the file and never imports the lexicon. behold’s own terraform member kind moved here, so an estate behold reads lists the same way in a chant workspace. See Workspace Kinds for the format.

The worked example lives in examples/getting-started/: a config, a terraform/ root with a local backend, and the entities it produces. examples/apply-gated/ pairs a gated TerraformApplyOp with an explicit rollback command.

chant