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:
| Phase | Fires on | Notes |
|---|---|---|
pre_init / post_init | every non-raw run | wrap tofu init |
pre_plan / post_plan | plan, destroy, refresh | plan-style commands; the planfile exists in post_plan |
pre_apply / post_apply | apply | the 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:
| Variable | Meaning |
|---|---|
GANTRYCD_RUN_MODE | plan / apply / destroy / refresh |
GANTRYCD_HOOK_PHASE | the firing phase, e.g. post_plan |
GANTRYCD_PLAN_FILE | absolute path to the planfile (present in post_plan and both apply phases) |
GANTRYCD_WORKING_DIR | the stack working directory |
GANTRYCD_RUN_ID / GANTRYCD_STACK_ID / GANTRYCD_STACK_NAME | run/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:
| Tool | Purpose |
|---|---|
conftest, opa | evaluate Rego policy against a plan |
infracost | cost estimation from a plan |
jq, yq | pull 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),Hooksfield ondomain.Stack/domain.Context. - Resolution:
internal/backend/services/hooks_resolve.go(ResolveEffectiveHooks), invoked fromDeploymentService.AcceptRunAssignmentand threaded throughbuildRunContext. - Contract:
RunContext.Hooks map[HookPhase][]RunHookinpkg/contracts/runner.go(see Run Context And Artifacts). - Runner:
internal/runner/ephemeral/hooks.go, wired into the execute loop inrunner.go. - Configure them on a stack’s or context’s Hooks tab in the web UI, or via the
hooksfield on the stack/context create and update API requests.