Module: internal/hooks · Milestone: M6 · Effort: ~2w (feature #5)
Declarative hooks that run user-supplied commands at well-defined points in the up/down
saga — on the host or inside a service container — with the same ${ref} and secret://
interpolation the rest of the config gets. Hooks are the missing glue between "containers are
up" and "the app actually works": DB migrations, first-run seeding, app-key generation,
npm/composer/bundle install. The load-bearing design move is a ledger-backed firstRun
that runs exactly once per provisioned data volume and survives restarts — the correct
replacement for Postgres initdb.d, which never re-runs on an already-populated shared volume
(spec 03, DECISIONS D8).
Hooks slot into named phases of the up/down saga (spec 08 §up-saga,
spec 09). They run after their gating phase succeeds,
never inside the global flock (see gotchas). DB-touching hooks fire only after the shared DB is
healthy (spec 10).
| Hook | Fires | Scope | Re-runs? |
|---|---|---|---|
preUp (optional) |
after generate, before compose up -d |
per-project | every up |
firstRun |
once after provision + DB healthy, before postUp |
per-project, keyed by data-volume identity | never once satisfied (ledger) |
postUp |
after compose up -d + dependents healthy |
per-project | every up (or guard with own ledger key) |
postPull |
after ws sync/clone advances a repo's HEAD |
per-project, keyed by resolved commit | once per new commit |
preDown |
before compose down/stop |
per-project | every down |
- Workspace-scope hooks (declared in
workspace.yaml) wrap the whole bootstrap: a workspacefirstRunruns once per workspace (scope_key = workspace name + shared-stack identity), a workspacepostUpafter all projects are up. Project-scope hooks (devstack.yaml) run per project. Ordering: workspacepreUp→ per-project phases → workspacepostUp; mirrored in reverse forpreDown. firstRunis not "firstup" — it is "first time on this data volume". A reset of the shared Postgres volume (or a fresh machine) re-arms it (see scope_key below).
Hooks attach to a project or the workspace, and (for service-targeted hooks) to a service
(spec 01 grammar; lists replace on overlay merge unless $merge: append).
# devstack.yaml (project scope)
hooks:
firstRun:
- name: migrate-and-seed
run: exec # host | exec
service: api # required for run: exec
command: ["sh","-lc","php artisan migrate --force && php artisan db:seed --force"]
workdir: /var/www/html # in-container path for exec; repo-root-relative for host
env: { APP_ENV: "${profile}", DB_URL: "secret://aws/api#db_url" }
timeout: 5m # Go duration; default 120s
retries: 0 # default 0; firstRun/migrate often want >0
onFailure: abort # abort (default) | warn | continue
postUp:
- { name: assets, run: exec, service: web, command: ["npm","run","build"], timeout: 10m }
preDown:
- { name: flush, run: host, command: ["./scripts/flush.sh"], onFailure: warn }run: hostexecutes viaos/execfrom the documented working directory;run: execshells into a running service viadocker compose -p <proj> exec -T <service> <command>.commandis an argv array (no shell parsing by us; usesh -lc "…"explicitly if you want a shell).${ref}/${env}/${self}are resolved by us before exec;secret://is resolved in the same batched pass as generation (spec 04).- Defaults:
timeout: 120s,retries: 0,onFailure: abort(warnforpreDown).
Both transports converge on one Runner that (1) interpolates command/env/workdir,
(2) resolves secret:// to values held only in memory, (3) builds the child environment, and
(4) runs under a context.WithTimeout.
host: exec.CommandContext(ctx, argv[0], argv[1:]...)
cmd.Dir = <repo-root | workspace-root> // documented, never CWD
cmd.Env = base ++ interpolatedEnv ++ resolvedSecrets // secrets last
exec: docker compose -p devstack-<proj> exec -T \
-w <workdir> -e NAME (valueless, value via our process env? NO — see below) \
<service> <argv...>
- Secrets reach
run: hostviacmd.Envandrun: execvia repeated-e NAME=VALUEflags oncompose exec(note:compose exec -edoes acceptNAME=VALUE, unlikeup). Secret values are never written to any generated file and never logged (ARCHITECTURE §7.5, spec 04);--debugredacts hook env values. -Tdisables TTY allocation so hooks are deterministic in non-interactive/CI contexts; output is captured (combined stdout/stderr) for the saga checklist and the failure remediation.run: execrequires the target service already running — that is whypostUp/firstRunfire aftercompose up -d; apreUprun: execis a config error (lint: service not yet up).
firstRun and postPull are tracked in the hook_run(ctx, project, hook, scope_key) table
already reserved in spec 08. A row exists iff that hook
is satisfied for that scope.
-- satisfied check (lock-free read) before running:
SELECT 1 FROM hook_run WHERE ctx=? AND project=? AND hook=? AND scope_key=?;
-- recorded ONLY on success, inside the flock:
INSERT INTO hook_run(ctx,project,hook,scope_key,ran_at) VALUES(?,?,?,?,?);firstRunscope_key = identity of the provisioned data volume. Concretely the tuple(shared_service, provisioned db/role name)from theprovisionedledger (spec 03 §isolation), or the Docker named-volume’sMountpoint/created-at for non-DB volumes. Because the key is the volume — not the container or theupcount —firstRunsurvivesdown/upand container recreation, and re-arms when the volume is dropped (db gc,workspace destroy, or a manualdocker volume rm). This is precisely the gapinitdb.dleaves open.postPullscope_key = resolved commit SHA (fromgitxafterws sync). Runs once per new HEAD; a no-op pull (same SHA) skips it.preUp/postUpare unconditional by default (everyup); a hook may opt into once-only semantics by declaringonce: true, which gives it a stable scope_key (name@digest-of-command).- Flags:
--skip-hooks[=firstRun,postUp]suppresses execution (records nothing);--force-hooks[=…]deletes the matchinghook_runrows first, forcing a re-run.up --no-firstRunis sugar.
| Hook | Default onFailure |
Effect on saga | Ledger on failure |
|---|---|---|---|
firstRun |
abort |
fails up; compensating actions per spec 08 |
no row written → retried next up |
postUp |
abort |
fails up (stack stays up; phase marked unsatisfied) |
n/a (or row deleted if once) |
preUp |
abort |
fails up before any container starts |
n/a |
postPull |
warn |
logs, continues | no row → retried next sync |
preDown |
warn |
logs, down proceeds |
n/a |
- The cardinal rule: a failed
firstRun/postPullrecords nothing, so the operation is naturally retried on the next run — the ledger only ever reflects success. This makes hooks resumable for free (re-runningupreruns only unsatisfied hooks). - Timeout kills the process group (
SIGTERMthenSIGKILLafter a grace) and counts as a failure; retries apply a fixed backoff between attempts, capped by the per-hooktimeoutfor the whole sequence. A hook that the userCtrl-Cs is a failure, not a success. onFailure: continueruns remaining hooks then still fails the phase;warndowngrades to a non-fatal warning.preDowndefaults towarnso a broken teardown hook can never trap a workspace in the "can't go down" state.
Hooks execute arbitrary commands declared in committed config — the same trust level as
running the repo's Makefile, an entrypoint, or a compose command:. v1 adds no sandbox:
no namespacing, no seccomp, no allow-list. The threat model is documented, not engineered around,
consistent with the secrets posture (ARCHITECTURE §7.5):
- Resolved
secret://values are visible to the hook child process and, while it runs, via/proc/<pid>/environto same-user processes. This is identical to how the app container already receives them. Do not pass non-local secrets to a hook you would not trust with the app's env. run: hosthooks run with the invoking user's privileges on the host (not in a container); treat a workspace from an untrusted source exactly like cloning andmake-ing it.- A CI test asserts no hook path ever writes a secret value to disk or to a generated file (spec 04 test parity).
| Use | Hook | run | Ordering note |
|---|---|---|---|
| DB schema migration | firstRun (+postPull for new migrations) |
exec |
after shared DB healthy (spec 10) |
| First-run seed data | firstRun |
exec |
after migrate; once per volume |
| App-key / secret generation | firstRun |
exec or host |
before postUp; persist to secrets provider, not git |
npm/composer/bundle install |
postPull (deps changed) or firstRun |
exec/host |
gate on lockfile-hash via once/scope_key |
| wait-for-it style gating | prefer dependsOn: healthy (spec 10); hook only for non-modeled deps |
exec |
health graph is the first-class mechanism |
Migrations and seeds are firstRun (idempotent-by-volume) plus a postPull keyed to the new
commit, so pulling a branch with new migrations re-runs them without forcing a full re-seed.
initdb.druns only on an empty PGDATA, and the freshness marker is a non-emptyPG_VERSION— a volume containing onlylost+foundstill counts as fresh, so a host-mounted dir can mis-trigger or mis-skip it.firstRunis ledger-tracked precisely to avoid relying on this marker (DECISIONS D8). PG18+ also moved PGDATA to/var/lib/postgresql.compose execrequires the service running (it has no implicit start, unlikerun); enumerate the target with the read-only SDK (All=true, excludeoneoff=true, correct context — spec 03) before exec'ing and emit a clear remediation if it's down.- Host hooks need a documented working directory:
run: hostdefaults to the repo root for project hooks and the workspace root for workspace hooks — never the process CWD (which moves with the user). This is part of the--jsoncontract for reproducibility. - Long hooks must respect the timeout and must NOT hold the global flock. The flock is taken only
around ledger mutations (the
hook_runinsert, port/ref rows); the hook body runs outside it, or a 10-minutenpm installwould serialize every other invocation on the machine (spec 08 #1 rule). compose exec -e NAME=VALUEworks;compose uphas no per-run env injection — that asymmetry is why generation emits valueless per-serviceenvironment:keys but hooks can pass values inline (DECISIONS D10, ARCHITECTURE §7.5).- argv, not a shell string — we never word-split
command; users who need globbing/pipes write["sh","-lc","…"]. This avoids the classic injection/quoting footguns and keeps--jsonfaithful. - A
kill -9between hook success and the ledger insert re-runs the hook next time (idempotent body assumed) — safe by design, and the reason hook bodies forfirstRunmust themselves be existence-guarded where they touch shared state.
- A project
firstRunmigration runs exactly once acrossup→down→up(no container reuse); no second run while the data volume persists. - Dropping the shared Postgres volume (
db gc/workspace destroy) re-armsfirstRun; the nextupre-migrates and re-seeds. - A failed
firstRunabortsup, writes nohook_runrow, and is retried (and can succeed) on the nextup. -
run: execagainst a stopped service yields a clear "service not running" remediation, not a raw compose error. - A
postPullhook runs once for a new HEAD and is skipped on a no-op pull (same SHA). - No generated file and no
--debuglog line ever contains asecret://-resolved value used by a hook. - A 60s hook does not block a concurrent
upin another terminal (flock not held during the hook body). -
--skip-hooksruns the saga with hooks suppressed;--force-hooks=firstRunre-runs a satisfiedfirstRun. - A
preDownhook failure warns but allowsdownto complete.
Consumes internal/orchestrate (phase boundaries + saga state), internal/state + internal/lock
(the hook_run ledger, written inside the flock), internal/config (hook schema + ${ref}/secret://
grammar), internal/secrets (batched Resolve), internal/docker (compose exec driver + read-only
service-state probe), internal/provision (the data-volume identity that keys firstRun),
internal/health (spec 10, DB-healthy gate), and internal/gitx
(spec 06, commit SHA for postPull). Consumed by internal/cli (--skip-hooks/--force-hooks
flags, up/down/ws sync surface) and surfaced in the up checklist (spec 09).
Effort: a thinner v1 (project-scope firstRun/postUp only, run: exec, no retries/workspace-scope)
lands in ~1w; full (workspace scope, postPull/preUp/preDown, retries, host hooks, --force-hooks) is ~2w.
Q-DAEMON (without a daemon, hooks run only for the duration of up/down; there is
no autostop-triggered hook). New: Q-HOOK-SCOPE — should firstRun's data-volume identity be the
provisioned (db,role) tuple (survives Postgres image upgrades that keep the volume) or the Docker
named-volume id (re-arms whenever the volume object is recreated, e.g. a compose volume rename)? They
diverge on the "upgraded the Postgres major but kept the data" path; v1 recommends the (db,role) tuple,
keyed alongside the provisioned ledger, and revisits if image-upgrade re-seeding becomes a real workflow.