Ops
Eight activities ship with the lexicon, resolvable by name via loadActivities(["terraform"]), plus three composite Ops built from them: a gated apply, a scheduled watch, and a gated adoption.
The activities
Section titled “The activities”Every call resolves the named root out of terraform.roots (walking up from cwd to the nearest chant.config.*, the same way chant build does), sets TF_IN_AUTOMATION=1, and passes -input=false everywhere the CLI accepts it — a missing variable fails the step instead of blocking on a prompt nobody answers.
| Activity | Runs | Notes |
|---|---|---|
terraformInit | terraform init | -backend-config flags come from the root’s backendConfig. upgrade adds -upgrade; reconfigure adds -reconfigure. |
terraformPlan | terraform plan -detailed-exitcode -out=<planFile> | Exit 0 means no changes, exit 2 means changes — both are success, everything else throws with the CLI’s own stderr attached. Returns the plan’s add/change/destroy counts and both a -json and a -no-color render, so a gate or a report downstream never re-plans. |
terraformApply | terraform apply <planFile> | A saved plan, on either kind of root: there is no bare-apply mode here, and a missing planFile is refused before the CLI ever runs, naming the Plan step’s own output as the fix. On a live root (see “choudoufu” below) that file is the approval artifact, and an apply that disagrees with it comes back as a named refusal rather than a thrown error. |
terraformShow | terraform show | Over a saved plan (planFile given) or over current state (omitted). terraform show takes no -input flag, so this one carries TF_IN_AUTOMATION alone. |
The builders
Section titled “The builders”terraformInit, terraformPlan, terraformApply, terraformShow also exist as typed step-builder wrappers (import { terraformPlan } from "@intentius/chant-lexicon-terraform"), the fully-typed twins of the activities above: opts is the activity’s own args interface, minus the positional root, so a field rename there is a compile error at the call site rather than a silent drop. root is positional because naming the root is the one thing a terraform step cannot be authored without.
import { terraformInit, terraformPlan, terraformApply, phase } from "@intentius/chant-lexicon-terraform";
const plan = terraformPlan("app", { id: "plan" }); // an id is what makes `.out` legalGive a Plan step an id when a later step applies its plan: plan.out.planFile builds a reference to that step’s own saved output, instead of a literal path a rename could silently orphan. That reference is exactly what TF101 enforces — see Lint Rules.
Default profiles: terraformInit/terraformPlan/terraformApply run on longInfra (they call providers or download them); terraformShow runs on fastIdempotent (it reads an artifact and calls no provider).
TerraformApplyOp
Section titled “TerraformApplyOp”The composite that phases the four activities into Init, Plan, an optional approval Gate, then Apply:
import { TerraformApplyOp } from "@intentius/chant-lexicon-terraform";
export const { op } = TerraformApplyOp({ name: "app-apply", root: "app",});| Option | Default | Notes |
|---|---|---|
name | — required | Op name (kebab-case). Also the default task queue and gate signal suffix. |
root | — required | Key into terraform.roots. |
planFile | chant.tfplan | Written by Plan, consumed by Apply — relative to the root dir. |
gate | "on-destroy" | "on-destroy" and "always" build the identical four-phase shape (GateStep carries no condition to branch a plan’s destroy count at build time — see below); "never" drops the Gate phase entirely. |
signalName | approve-<name> | Gate signal name. |
gateTimeout | core’s own (48h) | How long a recorded pending gate stays valid, as a duration string. |
gateDescription | a generated description naming the root and gate mode | Override text shown to the approver. |
upgrade | false | -upgrade on the Init step. |
cwd | the process’s cwd | Directory each step starts its chant.config.* search from. |
compensate | unset | Saga-style rollback on a failed apply — see below. |
taskQueue | name | Override the task queue. |
The gate
Section titled “The gate”gate: "never" drops the Gate phase, so chant run walks straight from Plan to Apply. Every other mode emits a Gate phase that re-renders the saved plan with a terraformShow step first (over the plan file, so it calls no provider) and reports its destroys count as a Destroys search attribute. A gate is a fact on the gate ledger: a run that reaches one nobody has approved records that it is waiting, ends with status gated and exits 3, chant approve <op> <gate> writes the resolution, and the next run reads it and walks through. "on-destroy" and "always" emitting the identical shape is deliberate: GateStep carries a signal name, a timeout and a description, nothing conditional, so "on-destroy" cannot itself decide at build time whether a given run’s plan destroys anything. The workable answer is to always emit the gate and tell the approver what is at stake instead of trying to skip it.
Compensation
Section titled “Compensation”Terraform has no automatic rollback — undoing a partial apply means planning and applying the inverse, a decision about the estate rather than something chant can synthesize. So compensate: true with no command throws at build time, naming the Op, rather than surfacing as a runtime failure once an apply has already half-run:
export const { op } = TerraformApplyOp({ name: "app-apply-gated", root: "app", gate: "always", compensate: { command: "terraform destroy -auto-approve" },});examples/apply-gated/ is the worked version of this shape.
choudoufu
Section titled “choudoufu”terraform.binary also accepts "choudoufu", an OpenTofu fork that carries ownership as two AWS tags on the resource itself rather than in a state file. A root only runs live under choudoufu when it also declares an estate, either form:
# in-block, inside the root's terraform blockterraform { live { estate = "prod-networking" }}# estate.chdf.hcl, a sidecar beside the root's .tf files, the leading form,# since it keeps every .tf file free of syntax stock tooling does not knowestate = "prod-networking"Declaring binary: "choudoufu" with no estate anywhere runs the root stock, exactly as "terraform"/"tofu" do; declaring an estate under any other binary parses fine but is inert, and chant dev check-lexicon/buildRoots() warns about it.
Four more activities exist for a live root, alongside the four above:
| Activity | Runs | Notes |
|---|---|---|
choudoufuLivePlan | live-plan -detailed-exitcode -json -estate=<estate>, plus a second live-plan without -json | Exit 0 means no drift, exit 2 means drift, and both are success. Returns GitHub issue #788’s JSON document captured whole (.out.documentPath names where it was written, under the root dir), the human-readable plan, and drift/unowned/adoptable counts projected from the document. estate is auto-detected from the root’s own live block or sidecar when omitted. Also returns the adoption ledger over that document (.out.ledger), the adoptable and contested candidate lists, and .out.finding, the plan text with the ledger under it. adoptionOnly makes the second run print choudoufu’s own -adoption-only ledger, whose paste-ready tagging commands ride back on each candidate. |
choudoufuLiveLs | live-ls -estate=<estate> -json [-consistent] | Lists every resource the account holds under the estate, straight off the Resource Groups Tagging API. No configuration, state or record store is read. |
choudoufuLiveCheck | live-check -json | Whether the root can move under live resource markers, and what stops it if not. Makes no cloud calls and needs no estate at all; refused: true on a non-zero exit carries the answer back rather than throwing. |
choudoufuAdopt | one tagging call per adoptable match | Writes the tofu-estate and tofu-address markers that claim the live resources an adoption ledger found. Takes the ledger’s own adoptions and contested lists; a contested address is reported and never written. See Reconcile. |
terraformInit, terraformPlan, terraformApply and terraformShow are reused as-is on a live root. Every choudoufu invocation still gets TF_IN_AUTOMATION=1. choudoufu version is checked once per activity-module load and refuses a binary older than v0.13.0, which is the release that shipped the approval artifact (choudoufu #878) on top of v0.12.0’s live-plan -json, live-ls and live-check -json.
Known upstream gap, for the two Ops that read the document. choudoufu’s live-plan -json refuses to run on a configuration that declares its own estate (choudoufu #894), which every chant live root does by construction. choudoufuLivePlan is therefore unusable against a real live root today, and the TerraformWatchOp and TerraformAdoptOp acceptance suites skip with that reason. TerraformApplyOp is not affected: its plan half is the stock plan -out path and reads no JSON document.
choudoufuLivePlan, choudoufuLiveLs, choudoufuLiveCheck and choudoufuAdopt exist as typed step builders too, the same shape as the four above (root positional, opts the activity’s own args minus root). choudoufuLivePlan/choudoufuLiveLs default to the longInfra/fastIdempotent profiles respectively the same way terraformPlan/terraformShow do; choudoufuLiveCheck defaults to fastIdempotent, no cloud calls, no state; choudoufuAdopt defaults to longInfra, one tagging round trip per resource.
TF001 (no remote backend) does not fire on a live root, since a backend block is exactly what choudoufu refuses there. TF024, TF025 and TF026 are the live-root equivalents; see Lint Rules.
TerraformApplyOp on a live root
Section titled “TerraformApplyOp on a live root”TerraformApplyOp builds the same four phases on a live root as on a stock one, and the same plan file crosses the gate: Init, plan -out=<planFile>, Gate on show over that file, then apply <planFile>. It ran a different shape for one release, when choudoufu refused -out and apply <planfile> outright. choudoufu v0.13.0 admits both under a live block (choudoufu #878), so the plan-file discipline is back.
What the approval covers. apply <planFile> on a live root does not replay the file. Prior state there is a projection rebuilt from the live system every run, so the apply re-plans against the live system the way every live-markers run does, and then compares its own fresh plan against the file’s: the same addresses, the same actions, the same live objects, and the same planned values down to the attribute. Agreeing, it applies without re-prompting, exactly as stock’s apply <planfile> does. Disagreeing, it refuses before anything changes, with one of two named refusals:
The approved plan no longer matches the live system, when the change set or the planned values moved, andThe approved plan belongs to a different estate, when the file was produced from a configuration naming another estate.
Both carry exit status 3, which is neither 1 (any ordinary failure, which a pipeline must not route back to a reviewer) nor -detailed-exitcode’s 2 on the plan half of the same pipeline. terraformApply maps that status to a result rather than a thrown error: refused: "approval-mismatch" | "wrong-estate", with choudoufu’s own message in refusal and applied: false. A workflow reading the Apply step’s output can send the run back to review instead of treating it as a broken step. Exit 3 is read this way only on a live root; on a stock root nothing produces it and every non-zero exit stays a failure.
The approvalArtifact seam #2106 left named and unused on the apply args is gone, because the thing it was a seam for has shipped in the stock form: the artifact is the plan file, and planFile is how it is passed.
TF101 (plan-before-apply) fires on a live root’s terraformApply call again, and reads no project config at all. The pairing matters more here than on a stock root, not less: the apply compares its fresh plan against the file it was handed, so a plan file spelled out as a literal path is an approval for a run nobody can trace back to a plan step.
Delete modes. chant’s delete: "never" | "owned-only" | "gated" (terraform.roots.<name>.delete) maps onto choudoufu’s policy block: "owned-only" is choudoufu’s own default verb for the undeclared_tagged quadrant (an orphaned marked resource) and needs nothing; "gated" needs nothing beyond the approval gate above; "never" requires the root’s policy block to set undeclared_tagged to "keep", "untag" or "report" — TF026 enforces that at build time, naming the setting to add. Separately, and regardless of delete, TerraformApplyOp refuses outright to build against a root whose policy sets undeclared_untagged = "delete" (account-scoped reconciliation, which needs a scope block): chant never proposes deleting a resource it does not own, and neither does an Op it generates.
TerraformWatchOp
Section titled “TerraformWatchOp”A second composite, TerraformWatchOp, polls a root’s state on a schedule rather than driving an apply. See the lexicon’s “Live Observation” doc page for how a scheduled watch reads a Terraform root’s state file as its ownership answer.
Pass live: true and the Plan phase becomes choudoufuLivePlan instead of terraformPlan:
export const { op } = TerraformWatchOp({ name: "estate-watch", root: "estate", live: true, schedule: "0 6 * * *", findingMode: "issue",});A stock plan answers one question, so a stock watch reports one thing: whether the root drifted. A live plan reads the live system against the markers, so it answers three, and the Plan step publishes all three off that single read: Drift from the -detailed-exitcode exit, Unowned for live resources sitting at a declared identity with no marker, and Adoptable for the subset an exact content match makes claimable. planFile is refused with live: true (live-plan writes no saved plan, and -out is still refused on its own -estate surface) and estate is refused without it.
In issue and pull-request mode the body is the -no-color plan text followed by the adoption ledger: one line per adoptable match with its address, its live identity and the two tag values that claim it, then any address more than one live resource sits at, listed and not offered. Only that text reaches the body. The -json document stays in the Op, the same rule the stock mode holds for terraform show -json and for the same reason: it carries live identities and attribute values for everything the run touched.
Reconcile: adoption, not regeneration
Section titled “Reconcile: adoption, not regeneration”Cloud-to-code is the position on the lifecycle dial chant #2089 left open for this lexicon, because stock Terraform has no typed path from a live resource back to HCL. On choudoufu the question is different rather than harder. Ownership is two tags, live-plan already names every live resource that exactly matches a declared block and carries no marker, and it prints the two values that would claim it. So reconcile here is “claim what the configuration already describes”, which is a tag write.
TerraformAdoptOp is that, in four phases:
import { TerraformAdoptOp } from "@intentius/chant-lexicon-terraform";
export const { op } = TerraformAdoptOp({ name: "estate-adopt", root: "estate" });| Phase | Step | What it settles |
|---|---|---|
| Check | choudoufuLiveCheck | Whether this root can move under markers at all. No cloud calls, so it is the cheapest place to find out that the ledger below would not be authoritative. |
| Ledger | choudoufuLivePlan with adoptionOnly | The adoption ledger, which is what the gate approves, plus the machine-readable document the Adopt phase acts on. |
| Gate | always | Adoption writes tags onto resources this estate does not yet own. There is no gate: "never" here, so every run stops at the gate until chant approve <op> <gate> records the resolution. |
| Adopt | choudoufuAdopt | The marker writes themselves, and a named refusal for everything it declined. |
| Option | Default | Notes |
|---|---|---|
name | — required | Op name (kebab-case). Also the default task queue and gate signal suffix. |
root | — required | Key into terraform.roots. Must be a live root. |
estate | auto-detected | The estate whose markers to look for; normally the root’s own live block or sidecar answers this. |
signalName | approve-<name> | Gate signal name. |
gateTimeout | core’s own (48h) | How long a recorded pending gate stays valid. |
gateDescription | a generated description naming the ledger | Override text shown to the approver. |
cwd | the process’s cwd | Directory each step starts its chant.config.* search from. |
compensate | unset | Refused without a command. See below. |
taskQueue | name | Override the task queue. |
The adopt mechanism
Section titled “The adopt mechanism”The write is the two tags, run through the paste-ready command choudoufu itself printed in the ledger. That command comes from choudoufu’s own provider configuration, so it carries the region and endpoint the plan just read rather than wherever an operator’s AWS CLI profile points, and it uses the right tagging verb per service rather than a guess. A type whose service has its own tagging call choudoufu does not spell out (IAM, Route53, S3) is refused with both marker values named, not adopted with an invented command.
choudoufu’s policy block has an adopt verb for exactly this situation, and its migration guide names policy { declared_untagged = "adopt" } as the bulk path. That would have been the smaller write, and it is not reachable from outside the configuration: a live block in a terraform override file is dropped in silence (choudoufu’s Module.mergeFile has no case for one), a second primary file carrying one is refused as a duplicate, and a sidecar beside an in-block form is refused as two sources of truth. What is left is editing the root’s own checked-in live configuration around an apply, which leaves an estate declaring “adopt everything declared and unmarked” if the run dies between the two edits. So chant writes the tags instead, which is what choudoufu’s own guide calls the whole contract.
Ambiguity, and compensation
Section titled “Ambiguity, and compensation”Two live resources at one declared identity are two unowned entries with the same address, and no single tag write claims that address. Those never enter the adoptable set. The Adopt step is handed them separately, so the Op’s result names what it declined and why beside what it wrote, and the Ambiguous search attribute on the Ledger phase counts them.
compensate is refused without a command, as on TerraformApplyOp. Undoing an adoption is untag, removing this estate’s marker from a live resource, which is a decision about the estate rather than something chant can synthesize; it is also not always the right undo, since a resource that already carried the markers is not one the run claimed.
live-import comes first, and is not an Op
Section titled “live-import comes first, and is not an Op”For an estate that still has its terraform.tfstate, do not start with the ledger loop above. choudoufu live-import -state=terraform.tfstate -estate=<name> reads that state file once, verifies each entry against the live system, and stamps markers on everything that verifies, which lands the whole estate in the “already marked” group with nothing left to adopt by hand. It is also the only path that answers the count/for_each blind spot: content matching never offers an indexed or keyed address, while live-import reads every instance, index and key included, straight out of the state. Two runs, the same two flags: the first prints a ratification report and writes nothing, the second stamps what the report showed as VERIFIED or DRIFTED.
chant does not wrap it in an Op, on purpose. It reads a state file, once, and chant does not host one: the file is the migration’s input rather than part of the estate, and an Op that took a path to somebody’s terraform.tfstate as an argument would be pretending otherwise. Run it by hand in the root directory, keep the state file until it has run, then let TerraformWatchOp and TerraformAdoptOp cover what is left. choudoufu’s “Migrate an existing estate” guide has the full sequence.