---
type: Guide
title: "Pre/post-run hooks and the health gate"
description: "A source hooks file of ordered pre and post run steps and a declared-probe health gate, composed into one ledger workflow."
status: stable
tags: [operations, ansible, hooks]
---

# Pre/post-run hooks and the health gate

A source can wrap its run in lifecycle steps: ordered `pre_run` and `post_run`
shell steps, and a post-run health/verification gate whose success is a declared
probe — an HTTP status, a DNS resolution, a TLS handshake with a not-after
floor, or a numeric metric threshold — not merely a process exit code. A run of
such a source becomes one step-ledger workflow:

```
[pre_run...]  ->  the play  ->  [post_run...]  ->  the gate
```

Each entry is a queue job the daemon's sequencer releases in order, running the
next only once the previous one succeeded. So a `pre_run` step that fails leaves
the play **blocked** — it never runs — and a gate probe that fails marks the
whole run **failed** even when the play itself exited 0. That last property is
the point: `tofu apply` or an ansible play exiting 0 does not mean the service
is reachable, and the gate is what turns "the tool returned 0" into "the thing
is actually up" before a cutover is called done.

## Declaring hooks

Hooks live in the source's checkout at `.exocomp/hooks.yml`, with an operator
override at `<data-dir>/hooks/<source>.yml` that wins when present — the same
override-wins precedence the source model uses everywhere.

```yaml
pre_run:
  - name: wait-db          # a shell step: an inline command...
    run: "pg_isready -h db"
  - name: seed             # ...or a script path, relative to the checkout
    script: scripts/seed.sh
    secret: pg             # optional: a run_secret binding, injected as named env

post_run:
  - name: warm-cache
    run: "curl -sf http://127.0.0.1:8080/warm"

gate:
  - name: web-up           # HTTP: 2xx by default, or a declared status
    http:
      url: http://127.0.0.1:8080/health
      expect_status: 200
  - name: cert-fresh       # TLS handshake + not-after floor (days)
    tls:
      address: example.internal:443
      min_days: 14
  - name: dns-cutover      # resolves, optionally to an expected value
    dns:
      name: app.example.internal
      expect: 10.0.0.42
  - name: lag-ok           # a numeric metric body compared to a threshold
    metric:
      url: http://127.0.0.1:9000/replication_lag_seconds
      op: "<"
      value: 30
```

A step names exactly one of `run` (an inline shell command) or `script` (a path
inside the checkout, run with `/bin/sh`). A gate probe names exactly one of
`http`, `dns`, `tls`, `metric`. Unknown keys, a step with neither `run` nor
`script`, and a probe that cannot decide are all **refused at define time** — a
gate that cannot decide would silently pass, which a safety gate must never do.

## Commands

```
exocomp hooks validate path/to/hooks.yml   # refuse a malformed file (exits non-zero)
exocomp hooks show <source>                 # print the resolved lifecycle
exocomp run <source> <playbook> --wait      # run the composed workflow
exocomp run status <workflow-id>            # read the ledger: which step, what state
```

`hooks validate` reads a file and reports the first problem; it is safe to run in
a repo's CI so a broken `hooks.yml` never reaches a source. `exocomp run` needs a
running `exocomp serve` — the daemon's sequencer is what drives the chain and
resumes it after a restart, exactly like the terraform→ansible handoff. The
playbook path is relative to the source checkout.

A source's lifecycle applies no matter which command runs the play, not just
`exocomp run`:

```
exocomp playbook run --source <source> <playbook> --wait   # a plain play, wrapped
exocomp handoff run --source <source> <workspace> <playbook>  # the apply->play chain, wrapped
```

`playbook run --source` composes the same `[pre_run] -> play -> [post_run] -> gate`
workflow; `handoff run --source` wraps its `apply -> play` chain as
`[pre_run] -> apply -> play -> [post_run] -> gate`. Both need a running daemon
when the source has hooks, for the same reason `run` does. A source with no
`hooks.yml` leaves either command behaving exactly as it did without `--source`.

`run status` shows each step and its state; a `blocked` step is the visible proof
that a failed predecessor retired the rest:

```
workflow run:app:... : failed
  step 1  hook          succeeded  job 4
  step 2  ansible       succeeded  job 5     # the play exited 0...
  step 3  verify        failed     job 6     # ...but the gate failed the run
```

The failing probe is named in the run's event stream (`gate FAILED: web-up`), so
"why did this fail" reads straight off the output.

## Boundaries worth knowing

- **Hooks run sandboxed, user-space.** Each hook is a shell step under the same
  nono tier as every other job: the checkout is granted read+write, the shell's
  tool tree read, and the data dir's database and keys denied regardless of what
  the hook does. Hooks run as the run user; nothing here runs sudo. A hook is
  local prep — it gets no network egress. The network is the gate's job.
- **The gate runs in the daemon, not a subprocess.** A probe is a control-plane
  read over a local or reachable endpoint, like drift collection — there is
  nothing to sandbox, and the daemon's redactor scrubs a probe's output. Each
  probe has a timeout; an endpoint that never answers is a failed probe, not a
  hang.
- **A hook's secret is a binding name, never a value.** `secret:` names a
  run_secret binding; the sealed values are resolved from the envelope at
  execution time and injected as named environment variables the hook inherits.
  They never touch the argv, the job row, or an event — the same credential plane
  a dynamic-inventory plugin or a provider authenticates from.
- **The TLS probe checks the not-after floor, not chain validity.** An internal
  step-ca or self-signed endpoint is a legitimate target; the handshake proves
  the endpoint serves TLS and `min_days` is the "warn me before the cert expires"
  ladder. Use a metric or HTTP probe when you need to assert chain trust.
- **A source with no `hooks.yml` pays nothing** — its run is just the play,
  unchanged.
