Skip to content

Look at a terragucci estate

terragucci plans, applies and checks drift for a Terraform or OpenTofu repo in your CI, and keeps every run’s report in a bucket. behold reads those reports and draws the repo’s roots as one graph, each card marked with what the newest run found about it.

Terminal window
npx @intentius/behold serve . --terragucci s3://acme-terragucci-reports
npx @intentius/behold serve . --terragucci ./reports # an `aws s3 sync` of the prefix
npx @intentius/behold serve . --terragucci https://reports.example.com

Nothing new runs in your CI and nothing is hosted. behold runs on your laptop, reads the checkout for the graph and the bucket for the verdicts, and reads no cloud. Serve the repo’s root: its chant.workspace.json declares no members (terragucci writes one for its gates), so behold reads the Terraform roots in the directory.

The keys from terragucci’s reports bucket, each with one GET and never with a list:

Key What behold takes from it
index.json every run, newest first, and the directory of each
<run>/report.json for each root, the newest tf-plan and tf-drift run that planned it, and the newest tf-apply wave that held it
estate.json the counts and the waiting waves, as terragucci’s estate page shows them
audit.jsonl the audit record, for the timeline, when the job writes one
<project>/runs/<commit>/run.json the newest apply’s progress per resource, for apply progress

With an s3:// source on a laptop, behold reads each key with your own aws s3 cp <key> -, so it uses your shell’s profile and region. In a job, where the credentials are in the environment, it signs its GETs itself. A read-only identity needs GetObject on <prefix>/*index.json, <prefix>/*/report.json and <prefix>/estate.json, the same policy terragucci’s estate job has.

When @intentius/terragucci is installed beside behold or in the repo, every document is checked against the JSON Schemas it ships. Without it, behold checks the schema id and the fields it reads, and refuses anything else with code: "terragucci-report" and what to do. behold looks for the package on every read, so installing or upgrading it while serve runs takes effect on the next read (within the 30 second cache, or at once with ?fresh=1).

A mark is what one run found, when that run finished, and where its report is:

  • ⚠ drift: the newest drift check found the resource changed outside Terraform, deleted outside Terraform, or existing outside the state.
  • ~ plan: the newest plan that holds the root would change the resource. The pull request is named.
  • ⏸ wave: a tf-apply wave holding the resource waits for an approval and would change it once approved.

Each card’s corner says how old its newest mark is. Clicking the card lists every mark in the inspect pane, worded the way terragucci words it (“drift found 06:04 UTC, 4h ago: deleted outside Terraform”), with links to the run’s report, job, pull request and trace. The Terragucci tab lists every root: its newest drift check, plan and apply wave, or “no report” when no run holds it, which is not the same as “no changes”.

Report pages open through behold, which reads them from the same source, so a link works for a private bucket too. They are served sandboxed.

When terragucci’s estate job runs terragucci audit, the top of the reports prefix holds audit.jsonl: one line per approval, approval request and revocation, policy override, apply, refused wave, state migration, lock release, state export and ephemeral copy, with who, when and the evidence (The audit trail). behold reads it with one more GET and draws it as a Timeline lane, newest first, under the roots in the Terragucci tab. Clicking a card shows the entries of its root in the inspect pane (the record names roots, never a single resource). Each entry keeps its time and its run, and links the run’s report the same way a mark does, plus its CI job or the ledger commit on chant/lifecycle.

An approval names no root of its own: behold gives it the roots of the request, apply or refusal of the same wave and plan digest. A source without audit.jsonl (the job does not run terragucci audit, or --terragucci names one project’s directory rather than the prefix) shows the sentence “has no audit.jsonl”, which is not the same as “nothing happened”. Every line is checked against dist/audit.schema.json when @intentius/terragucci 0.4.7 or newer is installed, and structurally otherwise; one bad line refuses the record with code: "terragucci-report" and its line number.

The read-only identity needs GetObject on <prefix>/audit.jsonl too. For an agent: GET /api/terragucci/timeline?root=<path>&limit=<n> (200 by default, 1000 at most).

A wave waiting for an approval appears as a gate card in the Terragucci tab: the roots it holds, what its kept report says it destroys or replaces, how long it has waited, and the line to run:

Terminal window
npx terragucci approve wave-2 --plan jcs1-sha256:2e0f…

There is no approve button. An approval is a git record on chant/lifecycle, bound to the wave’s plan digest and, under approval: sealed, signed with your key, so it is made at your shell. Copy the line from the gate card, digest included: --plan makes terragucci approve only the plan you looked at, and refuse if the wave now holds a different one.

On a terragucci repo behold refuses every write (Deploy, Op runs, approvals, pipeline dispatch, rollback, and any write route added later) with 409 and code: "terragucci". The refusal’s remedy is the same npx terragucci approve wave-<k> --plan <digest> line. The only writes it still answers stay inside behold: saving the hand layout, switching the served project, refreshing caches, and the carve walkthrough’s scratch steps. The pipeline is the only way to apply.

The reports say what a wave asked for when its run finished. The gates and locks themselves live on the repo’s chant/lifecycle branch, and the Terragucci tab reads them too:

  • each wave’s gate from _gates/tf-apply.jsonl and _gates/tf-apply/applied.jsonl: waiting (with the approve line), approved and by whom, applied, failed, or a request that expired;
  • each locked root from _locks/tf-apply.json: the pull request holding it, who took it, when, and whether a plan, an apply or /terragucci lock did.

Every gate and lock names the commit of chant/lifecycle it was read from and when that commit was made, and the tab says when behold last read the branch.

behold fetches the branch from your checkout’s origin with your own git and credentials, into a cache of its own (<tmpdir>/behold-lifecycle-*). It never fetches into your checkout and never moves one of its refs. When the fetch fails (offline, or no access), the tab shows your checkout’s origin/chant/lifecycle as of your last git fetch, and says so. A repo with no chant/lifecycle yet shows that no gate has asked and no root is locked.

While the page is open, behold checks every 30 seconds whether the reports’ index.json changed (a conditional request: If-None-Match for a bucket or an address, the file’s time for a directory) and fetches chant/lifecycle again, and the page updates its marks and gate cards without a reload. Nothing is polled when no page is open. Change the interval, or turn it off with 0:

Terminal window
npx @intentius/behold serve . --terragucci s3://acme-terragucci-reports --terragucci-poll 120

With an s3:// source on a laptop, the check is aws s3api head-object --if-none-match, so the identity needs GetObject on <prefix>/index.json, which it already has. behold approves, locks and unlocks nothing: unlock is a pull request comment, approve is the line on the gate card.

A choudoufu repo’s tf-apply wave job writes its progress per resource into the run view while it applies: each resource its plans change, done, in flight, waiting, or not applied once its root’s apply failed, as of the job’s last read of the estates’ records. behold reads the run view of the newest commit index.json names an apply of (<prefix>/<project>/runs/<commit>/run.json, one more GET) and tags each card of the wave at its top corner, for example ▶ wave 2 in flight · 2m. The age is that of the records read, not of the page. The tag is never a fill, and the Terragucci tab lists each wave’s counts with the same read time.

While a wave is applying, or a resource is in flight or waiting, the 30 second poll also asks whether that run.json changed (If-None-Match, or the file’s time for a directory) and updates the tags without a reload. Once every resource is done or not applied it stops asking. A source with no run view (no reports.bucket, or a Terraform repo, whose waves write no progress) says so, which is not the same as “nothing is applying”. The read-only identity needs GetObject on <prefix>/*/runs/*/run.json. For an agent: GET /api/terragucci/progress.

When the source holds estate.json, the drifted, failed and waiting counts behold shows are the estate page’s own, and the tab links estate.html. behold never counts them again, so the two never disagree. behold owns the resource graph: which resources, in which roots, drifted or will change.

For a team that wants a link rather than a command, the job that already runs terragucci estate can publish a static view to the reports bucket. terragucci keeps <prefix>/views/ for a viewer’s files and never writes there. One more step in that job, after terragucci estate:

Terminal window
npx -y -p @intentius/behold -p @intentius/chant-lexicon-terraform@^0.121.0 -p @cdktn/hcl2json@^0.24.0 \
behold export . --terragucci s3://<bucket>/<prefix> --no-source \
--publish s3://<bucket>/<prefix>/views/behold

The two Terraform packages are the ones behold reads HCL with, at the ranges it declares. behold is built and tested on chant 0.121, so the lexicon range is ^0.121.0: the lexicon’s own peer on @intentius/chant holds its minor, and a lexicon from another minor installs a second chant beside it that behold’s reader never resolves. behold’s chant reads a root whose chant.workspace.json asks for minReader 0.108.0 or anything up to 0.121.

The step reads the reports and writes the view with the job’s own credentials from the environment (static keys, or a role assumed with the job’s OIDC token), so the job needs no aws CLI. Add PutObject and GetObject on <prefix>/views/behold/* (and DeleteObject there, for the sweep below) to the job’s policy, beside the reads terragucci estate has and GetObject on <prefix>/*/report.json. --publish writes only under a views/<name> directory, so a typo never puts the view’s index.html over the prefix’s own.

The view is a static folder: the same marks with the same dates, the gate cards and their lines, and terragucci’s counts. Its report links are relative to <prefix>/views/behold/<commit>/, so they open the bucket’s own report pages through the same presigned link or front door as estate.html. It carries no credential, no user name and no path of the machine that made it, and --no-source leaves each root’s HCL out.

The upload is ordered so a reader never opens a half-written view: the snapshots and the SPA’s files first, then manifest.json, then index.html. Each snapshot’s name carries a hash of its contents and is uploaded with Cache-Control: public, max-age=31536000, immutable; manifest.json, index.html and the SPA’s other files get Cache-Control: no-cache, so a browser or CDN asks the bucket again and finds the new manifest. Before uploading, the step reads the previous manifest.json (one GET), and after the new index.html it deletes the snapshots that manifest named and this export did not write, only under <prefix>/views/behold/. That needs DeleteObject on <prefix>/views/behold/*; without it the step prints a warning naming s3:DeleteObject, leaves the old snapshots, and still succeeds.

Each run keeps its own view, filed under the commit it was exported from:

<prefix>/views/behold/<commit>/ the view, as above (index.html, manifest.json, snapshots/, ...)
<prefix>/views/behold/history.json newest first: [{commit, at, project, generated}]
<prefix>/views/behold/latest.json {commit}
<prefix>/views/behold/index.html opens the commit latest.json names

The commit is the checkout’s HEAD, or --commit <sha>. The ordering above applies inside <commit>/; after that commit’s index.html the step rewrites history.json (one GET of the old one, no list call), then latest.json, then the root index.html, all no-cache. The root page is a few lines that read latest.json and open <commit>/index.html with the link’s query and fragment, so a link to <prefix>/views/behold/index.html keeps opening the newest view. Inside a view, a picker beside the “static snapshot” pill reads ../history.json and opens another commit’s view, each labeled with its commit and when it was exported. at is the commit’s own date and generated the export’s.

--keep <n> (30 by default) is how many commits history.json holds. A commit past it leaves history.json, and its files are deleted as that commit’s own manifest.json names them (index.html first, manifest.json last), with the same DeleteObject permission and the same warning as the sweep above. Nothing outside <prefix>/views/behold/ is touched. A view published before this layout, its files directly under views/behold/, is left where it is; only its index.html becomes the page that opens the newest commit.

Make it a step of its own that may fail: a view that cannot be built leaves the estate page as it was, and behold export fails rather than publishing a picture with no marks.

A bucket prefix can hold many repos’ runs. behold picks the project named by the checkout’s git remote (github.com/acme/infra). If the index holds several and none is this checkout, it asks for one:

Terminal window
npx @intentius/behold serve . --terragucci s3://acme-reports --terragucci-project github.com/acme/infra

A control repo lists its projects under projects: in terragucci.yml and holds no Terraform root, so there is no card to mark. Serve it with the top of the reports prefix, where terragucci estate writes estate.json:

Terminal window
npx @intentius/behold serve . --terragucci s3://acme-reports

behold draws one box per project with its roots grouped by wave, a solid arrow for each read inside a project and a dashed one for each read between projects. It reads estate.json once, then each project’s newest run view, <project>/runs/<commit>/run.json, once. The commit comes from estate.json: the project’s run_view when it names one, otherwise its newest apply. A read between projects is a root’s terraform_remote_state read of a state that a root of another project holds, matched the same way terragucci’s estate page matches it.

Each box shows terragucci’s counts for the project, dated by estate.json’s generated, and the run view it was drawn from, dated by when a wave last wrote it. Under the picture, each project lists its waiting waves with the approve line to copy. The approve line comes from the run view, so it carries the plan digest; run it in the project’s own checkout, because behold approves nothing. Each project also links its index, the estate page and its run view. When the project’s repo is checked out next to the control repo (a sibling directory whose git remote names the project), the list shows the behold serve <path> --terragucci <src> line that opens that repo.

A project with no run view (no apply yet, or none in the bucket) stays on the page as a box that says “no run view”. A run view that does not match terragucci’s run.schema.json is refused on its own project, and the other projects still draw. A project listed in terragucci.yml that estate.json does not list is shown as “not in estate.json”. The read needs GetObject on <prefix>/estate.json and <prefix>/*/runs/*/run.json, and no list call.

behold ships a reports bucket for terragucci’s own example estate, written by terragucci’s code (example-terragucci-reports/ in behold’s repo, whose README lists the runs). From a terragucci checkout:

Terminal window
npx @intentius/behold serve example --terragucci <behold>/example-terragucci-reports

It shows a drift on staging orders’ jobs queue, two pull requests’ plans, a failed drift check on prod payments, and wave 2 waiting with a destroy. Its audit.jsonl gives the timeline wave 1’s apply and wave 2’s request.