Skip to content
GantryCD

Lifecycle Hooks

Lifecycle hooks are bash scripts the runner executes around a run’s OpenTofu lifecycle — before/after tofu init and before/after the main command. They cover the work that does not belong in Terraform itself: connecting a VPN, installing tooling, evaluating a policy/cost tool (conftest, OPA, Infracost) against the generated plan, or backing up state after apply. Hooks are configured per stack and per context; a non-zero exit fails the run, so a hook doubles as a gate.

A stack that declares no hooks (and references no context that does) behaves exactly as before.

Phases

A non-raw run always runs tofu init then exactly one main command. Six phases wrap those two steps:

PhaseFires onNotes
pre_init / post_initevery non-raw runwrap tofu init
pre_plan / post_planplan, destroy, refreshplan-style commands; the planfile exists in post_plan
pre_apply / post_applyapplythe planfile (downloaded for apply) exists in both

destroy and refresh are plan-style (tofu plan -destroy / -refresh-only), so they use the *_plan phases. A raw deployment is already arbitrary bash with no init/plan/apply structure, so it runs no hooks. A script can branch on $GANTRYCD_RUN_MODE when it needs to distinguish plan vs destroy vs refresh.

Ordering

Every hook carries a priority in [1,99]. Within a phase, all hooks — the stack’s own plus every referenced context’s — run in ascending priority order (lower first), regardless of source. At equal priority, contexts wrap the stack: a context hook runs before the stack on pre_* phases and after it on post_* phases. Remaining ties break deterministically by the stack’s context-reference order, then declaration order.

The backend computes this order once at run-accept time (services.ResolveEffectiveHooks, the single ordering authority) and ships the resolved per-phase slices in RunContext.Hooks; the runner executes them as-is.

Execution environment

Each hook runs from the stack’s working directory via:

bash -e -o pipefail -c <script>

-e and -o pipefail make a failed step fail the hook (and the run). Commands are not traced — only what the script itself prints reaches the run log; a hook that wants a trace runs set -x itself. The hook inherits the same environment as tofu — cloud runtime credentials, the per-run gantrycd-API token (GANTRYCD_PAT), and the stack/context environment variables — plus:

VariableMeaning
GANTRYCD_RUN_MODEplan / apply / destroy / refresh
GANTRYCD_HOOK_PHASEthe firing phase, e.g. post_plan
GANTRYCD_PLAN_FILEabsolute path to the planfile (present in post_plan and both apply phases)
GANTRYCD_WORKING_DIRthe stack working directory
GANTRYCD_RUN_ID / GANTRYCD_STACK_ID / GANTRYCD_STACK_NAMErun/stack identity

Hook output is written to the same log stream as tofu, so it appears inline in the live tail and the archived logs, with a === GantryCD hook: <phase> (<source>) === banner before each script.

Preinstalled tooling

The ephemeral runner image ships the policy and cost tools hooks reach for most often, so the common gate does not re-download them on every run:

ToolPurpose
conftest, opaevaluate Rego policy against a plan
infracostcost estimation from a plan
jq, yqpull fields out of plan JSON / YAML

Each version is pinned as a *_VERSION build arg in cmd/runner/ephemeral/Dockerfile and verified against the publisher’s own SHA-256 before it is unpacked, so a bump never introduces an unverified binary. tofu, tenv and git are present for the runner itself.

Cloud CLIs (aws, gcloud, az) are deliberately not preinstalled — together they would add roughly 2 GB to an image that is pulled per run, and none of the three publishes a plain SHA-256 the build could gate on. A hook that needs one installs it itself; curl, wget, zip/unzip and xz-utils are in the image for exactly that.

Failure semantics

A non-zero hook exit fails the run at that point — later hooks and (for pre_* / post_plan) the tofu command or plan upload do not run. This is what makes post_plan a policy/cost gate:

tofu show -json "$GANTRYCD_PLAN_FILE" > plan.json
conftest test plan.json          # non-zero → run fails, no apply

A hook that should not fail the run guards itself: some-command || true.

Storage and security

Hooks are stored in plaintext in a hooks jsonb column on stacks and contexts (alongside labels). They are not encrypted — keep secrets in environment variables (encrypted at rest) and reference them from the script; never inline a secret.

Hooks are not traced, so a command line that expands a secret (e.g. curl -H "token: $SECRET") does not by itself reach the run log. Anything the script prints still does, and run-log output is not masked — a hook that turns on set -x, or runs env / printenv, exposes every secret in its environment to anyone who can read the run’s logs, the same trade-off raw runs carry.

Hooks run arbitrary bash with the run’s cloud credentials on every non-raw run — including pull-request preview plans and gantrycli local-plan runs of unreviewed code. That is acceptable because hooks are operator-set configuration: editing them is gated by the same (stack, update) / (context, update) permission as the rest of stack/context config. The pull request or local working tree supplies only the Terraform code, never the hooks.

Where it lives

  • Domain: pkg/domain/hooks.go (HookPhase, Hook, Hooks.Validate), Hooks field on domain.Stack / domain.Context.
  • Resolution: internal/backend/services/hooks_resolve.go (ResolveEffectiveHooks), invoked from DeploymentService.AcceptRunAssignment and threaded through buildRunContext.
  • Contract: RunContext.Hooks map[HookPhase][]RunHook in pkg/contracts/runner.go (see Run Context And Artifacts).
  • Runner: internal/runner/ephemeral/hooks.go, wired into the execute loop in runner.go.
  • Configure them on a stack’s or context’s Hooks tab in the web UI, or via the hooks field on the stack/context create and update API requests.