Skip to content

Run Hooks

A hook is a shell command that a run executes before or after one of its tool steps: initialization, planning or applying. Use hooks to install and run a scanner after the plan, fetch a secret from your vault before the plan, or send a notification after the apply.

Where hooks come from

  • The stack. Edit Stack > Hooks groups the six hook points under Initialization, Planning and Applying. Each point takes an ordered list of commands; drag to reorder. The stack's Configuration tab shows the hooks it has.
  • Configuration bundles. A bundle can carry hooks with the same shape and limits, so one bundle (a scanner, say) covers every stack it is attached to. Bundle hooks are part of the bundle's content and are set through the API: PUT /api/v1/bundles/{id}/content takes a hooks object next to the variables and files. Changing them is a content change, like editing a variable.

A run executes one merged list per hook point: first the hooks of the stack's bundles, then the stack's own hooks. Among the bundles, those you attached run first, in ascending attachment priority. Each source keeps its own order. A bundle hook can leave a file in the workspace for the stack's hook at the same point to read.

Limits, per source: 32 commands per hook point, 4096 bytes per command, no empty commands.

Hooks are frozen on a run when it is created. Editing a stack's hooks affects later runs only. Editing an attached bundle after a run was created makes that run fail with PLAN_STALE at its next configuration download; start a new run to pick up the change.

When hooks run

Run Steps, in order
Tracked, plan phase Before init, init, After init, Before plan, plan, After plan
Tracked, apply after approval Before init, init, After init, Before apply, apply, After apply
Proposed init hooks, plan hooks
Destroy init hooks, then Before apply, destroy, After apply

Commands run in order, and the first one that fails stops the run. A failing before hook skips its tool step. After hooks run only when the tool step succeeded. There is no cleanup or "finally" hook.

The apply after approval runs on whichever worker claims it, in a fresh workspace: nothing a plan-phase hook installed is still there. Install what the apply hooks need in Before apply, or in Before init, which runs in both executions.

The sandbox

Each command runs as sh -c in the same sandbox as the tool, with the project directory as its working directory, the run's environment variables and cloud credentials, and the tool (terraform or tofu) on PATH. The default image provides sh, wget, tar and gzip; it does not have curl, git, jq or Python. Host-mode private workers run hooks directly, as the worker's operating-system user.

  • On container workers every command is its own container. Anything written outside the workspace is gone when the command ends.
  • The workspace, and $HOME inside it, lasts for one execution of the run. It does not carry over to the apply after approval, to a retry, or to another run.
  • Nothing a hook exports reaches the next hook. Call what you installed by its path every time, and do not override HOME.

Every hook also receives these variables, which override a variable of the same name (the run log notes it):

Variable Value
ZENFRA_RUN_ID The run's ID
ZENFRA_RUN_TYPE TRACKED, PROPOSED or DESTROY
ZENFRA_STACK_ID The stack's ID
ZENFRA_PHASE The hook point, for example before_plan or after_apply
ZENFRA_PROJECT_DIR The working directory as the script sees it

Each command may run for 10 minutes by default (a private worker sets its own limit with ZENFRA_HOOK_TIMEOUT), within the run's overall deadline. A command past its limit is killed and the run fails with hook_timeout.

Hooks run again whenever their step runs again: a run requeued from the dead letter queue runs every hook again, and the apply after approval runs the init and apply hooks on its own worker. Write hooks that are safe to run twice.

Output and logs

A hook's output goes into the step's log on the run page, between [Zenfra] hook <point> <i>/<n> started and [Zenfra] hook <point> <i>/<n> exited <code> in <duration>. The command text itself is never written to the run log or to the run's error message.

Zenfra's automatic redaction hides common secret shapes in every log: values after keywords such as password= or token:, JSON fields named like a secret, and long random-looking strings. A short or ordinary-looking secret passes through it. A hook that fetches or builds such a secret registers it with ::add-mask before anything prints it.

Control lines

A hook talks to Zenfra by printing a control line on its output. There is one today: a line that starts with ::add-mask followed by a space. Zenfra acts on it and does not write it to the log. Any other line, including one that starts with ::, is ordinary output.

Masking values

TOKEN=$(vault kv get -field=token secret/ci)
echo "::add-mask $TOKEN"

From that line on, every occurrence of the value in the run log is written as ***. In place of the control line the log shows [Zenfra] add-mask: value <n> registered.

  • Syntax. The line starts with ::add-mask and one space. The value is the rest of the line exactly, with spaces kept; only a trailing carriage return is dropped. Register several values with one line each.
  • Print it with a single echo, and write nothing to stderr at that moment. A hook's two output streams reach Zenfra as one; an error message that lands inside the control line becomes part of the value, so the wrong value is registered and the real one prints unmasked later.
  • What is masked. Every later occurrence, for the rest of the run: the output of the following hooks and of the tool, and the apply after approval, even when it runs on another worker and does not repeat the plan hooks.
  • Earlier output is not rewritten. Anything printed before the control line keeps the value, unless the automatic redaction caught it. Register a value before anything prints it.
  • Encoded forms are separate values. A base64, URL-encoded or JSON-escaped copy of the value is different text; register each form you print.
  • Rules. A value is 4 to 4096 bytes, and a run registers at most 64 different values. Registering the same value again is fine and does not count.
  • Where the value is kept. Zenfra stores each registered value encrypted with your organization's key, so the apply after approval can mask it on any worker. A retry or a requeue clears the stored values and the hooks register them again; a finished run keeps them until the run itself is deleted. No page in Zenfra and no user-facing API response shows them.
  • Audit. Each accepted value is recorded in the audit log as run.mask_added with the run's count of values. The value itself is never recorded.

When a value is rejected

Masking fails closed. A value that breaks a rule, or that Zenfra cannot store, stops the hook at once: the rest of the hook's output is dropped and the run fails. The reason is one of too_short, too_long, limit (more than 64 values) or registry (Zenfra could not store it). The value is never part of the message.

If Zenfra is briefly unreachable, the worker retries the registration until the hook's own time limit. When that limit expires first, the run fails with hook_timeout instead, because the time limit is what stopped the hook.

Workers

Masking needs a worker that supports it; see Log masking for private pools. The apply of a run that registered values is claimed only by such a worker. A worker without support does not recognise ::add-mask: it writes the line to the log as ordinary output, value included, and applies only the automatic redaction.

Failures you may see

Code Message Meaning
hook_failed before_plan hook 2/3 exited 1 A before hook exited non-zero; the tool step did not run.
hook_failed after_plan hook 1/1 exited 1 The plan's summary is kept, but no plan is stored, so the run cannot be applied.
hook_failed apply succeeded; after_apply hook 1/1 exited 2 The apply (or destroy) completed and the state is written; only the hook failed.
hook_timeout before_plan hook 1/1 timed out after 10m0s A command ran past its limit, including while an ::add-mask registration was still being retried.
hook_failed before_plan hook 1/1 stopped: add-mask rejected (too_short) An ::add-mask value was rejected; see When a value is rejected.
mask_fetch_failed fetch log masks: … The apply after approval could not load the run's masked values, so nothing ran.

Cancelling a run during a hook kills the hook and ends the run Cancelled.

A run with any hook command, from the stack or a bundle, is claimed only by workers that support hooks; on a private pool that has not been upgraded it stays Queued.