Skip to content

Observation

chant reads a terraform root two ways, and they answer different questions.

TerraformWatchOp asks has the world moved? It runs terraform init, then terraform plan -detailed-exitcode, and reports the answer terraform itself gives: exit 0 is a plan with nothing in it, exit 2 is drift. That boolean leaves the Op as the Drift search attribute, and on a finding mode of issue or pull-request the plan is opened as one.

describeResources() asks does this declared block exist? On a stock root it runs terraform show -json over the root’s current state and matches values.root_module.resources[], child_modules[] included, against the <root>/<address> keys buildRoots() produced at build time. That is what chant lifecycle diff <env> --live and chant lifecycle plan read.

On a live root it runs choudoufu live-plan -json instead and matches that document’s sections, for the reason the next two sections give: a live root has no state file to show.

The state file is the ownership answer, on a stock root

Section titled “The state file is the ownership answer, on a stock root”

Lifecycle models draws three axes, and the third one is “who answers is this mine?”. It has three rows: a trusted state file, a live ownership marker, or nobody.

chant sits on the live ownership marker row almost everywhere. A chant-managed AWS resource carries chant:managed-by as an ordinary tag; a Kubernetes object carries app.kubernetes.io/managed-by as an ordinary label. Ownership is a fact recorded on the resource, read back on every observation, and no file has to be hosted or locked for it to be true.

Terraform sits on the trusted state file row, and this lexicon does not pretend otherwise. There is nowhere for chant to stamp a marker here: a resource’s provider-side tags belong to whoever wrote the HCL, and writing into them would edit an estate this lexicon promises never to write. What terraform has instead is the record chant declines to host elsewhere, and that record already says precisely which addresses the configuration manages.

So the rule is state membership, and it is the whole rule:

  • an address terraform show -json returns is owned;
  • anything else is unknown.

Never foreign. foreign would mean chant read a marker and found somebody else’s name on it. There is no marker, so there is no such reading to report.

A declared module block is the case where the difference shows. The state carries the module’s resources, never a row for the block itself, so chant can see the block is live without the state ever saying that block is managed. It comes back present with ownership: "unknown", and an unknown never becomes a delete.

The marker is the ownership answer, on a choudoufu root

Section titled “The marker is the ownership answer, on a choudoufu root”

Set terraform.binary to "choudoufu" and declare an estate, and the root moves to the other row. choudoufu writes two ordinary AWS tags in the create call, tofu-estate and tofu-address, rebuilds prior state from the live system on every run, and keeps the state file, where it keeps one at all, as a disposable cache. It is never consulted for ownership, live wins on any disagreement, and losing it costs one slower run.

So on a live root the marker is the ownership answer, and terraform sits in the same row as every other chant lexicon: a fact recorded on the resource, read back on every observation, with no file to host or lock. The spec for those tags is live/MARKERS.md in choudoufu’s own repository, and it is the one surface that project asks external tools to rely on.

chant does not re-derive the verdict from the tags. live-plan -json has already split every declared instance four ways, and the mapping is one table:

live-plan -jsonchant
a bound instancepresent, owned; the estate is surfaced as the resource’s marker when the bind came from the marker sweep
a bound instance whose identity came from the configurationpresent, owned by derivation, and said so in boundBy
an unowned resource at a declared identitypresent, foreign; something is in the way and the plan will not touch it
an adoptable matchpresent, unknown, carrying the exact tofu-estate and tofu-address values one tag write would claim it with
an omissionnot-observed with the reason mapped, ABSENT excepted

ABSENT is the one omission that is not a claim of ignorance. It means the provider was asked and reported that no such object exists, which is chant’s OBSERVED-ABSENT and the only shape that may become a create. Every other omission reason is a reason chant could not look, and those are not-observed.

Because a chant entity is the HCL block and the document’s rows are instances, a block with count or for_each takes the aggregate of its instances, and the least certain instance wins: one not-observed instance makes the block not-observed. Partial knowledge is not knowledge.

What the estate holds that nothing declares

Section titled “What the estate holds that nothing declares”

choudoufu live-ls -estate -json lists the whole estate off the Resource Groups Tagging API, reading no configuration, no state and no record store. That answers a question a state file cannot: which resources does this estate own that the configuration has stopped declaring?

Those owned orphans are what observeAmbient() reports and what teardownOwned() names, and they are the same set choudoufu’s own default policy verb (undeclared_tagged = "delete") would remove on the next apply. The read always passes -consistent, because that tag index lags a write by about a minute and a listing taken straight after an apply would otherwise show a resource under both its old estate and its new one, or under neither.

A declared instance the listing cannot reach comes back in its own gaps section, on the record or declaration-carried rung, and chant reports it as a teardown hole rather than leaving it out. A resource type with no tags argument never carried a marker, so its absence from a tag listing says nothing about whether it is there.

Only resource and data blocks have rows in state. A terraform, provider, variable, output or locals block is reported as not-observed with reason unsupported-kind. That is honest rather than a gap: those blocks have no live counterpart to look for.

A live root reads one block narrower still. live-plan’s document is prior state for managed resources, so a data block has no row in it either and is reported unsupported-kind there, where a stock root can answer for it.

Per the observation contract, a declared entity comes back in one of three states, and “absent” is only one of them. If a root’s init or show fails, for a held state lock, an unreachable backend or missing credentials, every entity declared in that root is reported not-observed with reason read-failed and the root named in the detail. A live root whose live-plan fails is the same, with the same mapping: no-credentials where the failure names credentials, read-failed for a refused configuration or any other error. None of them is reported absent, because absent plus declared classifies as create, and a broken read must not propose recreating an estate that is sitting there working.

Roots are read one at a time and merged, so a root with a broken backend never un-observes a root that answered.

terraform show -json over a plan file carries the attribute values the plan touches. Some of those are secrets that no sensitive marking covers, because sensitive_values describes what the configuration declared, not what a provider put in the plan. TerraformWatchOp therefore posts the -no-color render and only that. There is no option to post the other, and the composite’s tests assert that no reference to it reaches the step that opens an issue or a pull request.

The same care applies to observation: an observed resource reports its address, type, mode, provider and root, plus the id as its physical id. Nothing else out of the state row’s values is surfaced.