Writing hooks¶
Hooks are executables inside your bundle. They are the only way to add product-specific logic without changing the manager, which is why the ABI is a stable contract rather than an implementation detail.
This page is how to write one. The reference is what you can rely on.
The shape of a hook¶
operations:
migrate:
kind: hook
command: ["hooks/migrate"]
timeout: 10m
A hook is a file in your bundle with the executable bit set. A declared hook
that is missing, or one that is not executable, is a validation error —
caught by release verify in your CI, before an operator ever sees it.
The working directory is the release root, so relative paths to your own files
work. The command is resolved against the release root too: a hook named
backup runs your hooks/backup, never something on PATH that shares the
name.
What a hook is told¶
Everything arrives as environment variables namespaced with your product name:
"${DEMO_DATA_DIR:?DEMO_DATA_DIR is required}"
"${DEMO_BACKUP_DIR:?}"
"${DEMO_SECRETS_DIR:?}"
"${DEMO_RESULT_FD:-3}"
Use :? as above. A hook that silently does nothing because a variable was
empty is much worse than one that fails loudly with the variable's name.
The full list is in the ABI reference.
What a hook says back¶
Three exit codes, and one file descriptor.
| Exit | Meaning |
|---|---|
0 |
It did the work |
2 |
Nothing to do |
| other | Failure |
Exit 2 is worth using. It is how apply reports "migrations: nothing to run"
rather than implying work happened, and it is what makes an idempotent run
readable.
Structured output goes to file descriptor 3, not stdout — stdout is your log, and a hook whose logging is constrained by the manager's parsing is a hook you will fight with:
printf '{"message":"migrated %s -> %s","schema_version":%s}' \
"$current" "$target" "$target" >&"${DEMO_RESULT_FD:-3}"
The migrate hook¶
The most consequential one, because what it reports decides whether a rollback is allowed later:
#!/bin/sh
# Migration hook.
#
# Exit 0 means migrations ran, exit 2 means the schema was already current.
# The schema version is reported on the result descriptor so rollback can
# later reason about compatibility without re-running this tooling.
set -eu
marker="${DEMO_DATA_DIR:?DEMO_DATA_DIR is required}/.schema"
target=12
mkdir -p "$(dirname "$marker")"
current=0
[ -f "$marker" ] && current=$(cat "$marker")
if [ "$current" -ge "$target" ]; then
printf '{"message":"schema already at %s","schema_version":%s,"skipped":true}' "$current" "$current" >&"${DEMO_RESULT_FD:-3}"
echo "schema already at $current"
exit 2
fi
echo "migrating schema $current -> $target"
echo "$target" > "$marker"
printf '{"message":"migrated %s -> %s","schema_version":%s}' "$current" "$target" "$target" >&"${DEMO_RESULT_FD:-3}"
Three things to copy from it:
- It reports
schema_version. That is how the manager knows what the database is at, and it is the number a future rollback checks against yourdatabase_schema_max. A migrate hook that does not report one leaves every later rollback decision uninformed. - It exits 2 when the schema is already current, so a re-
applysays "nothing to run" instead of claiming a migration. - It is idempotent.
applyis idempotent, so everything it calls has to be. Check the current state and do nothing if it already holds.
A migration cannot be undone by the manager
migrate is deliberately not compensable. If it fails partway, no automatic
action the manager knows about can put the database back, so the operation
ends in requires-manual-intervention — exit 12 — and keeps surfacing until
a human clears it. Write migrations that fail cleanly.
The backup and restore hooks¶
#!/bin/sh
# Backup hook.
#
# The manager creates and owns BACKUP_DIR; this hook writes the product's own
# data into it and reports what it produced, with checksums, on the result
# descriptor.
set -eu
dir="${DEMO_BACKUP_DIR:?DEMO_BACKUP_DIR is required}"
data="${DEMO_DATA_DIR:?DEMO_DATA_DIR is required}"
echo "dumping database"
if [ -f "$data/.schema" ]; then
cp "$data/.schema" "$dir/database.sql"
else
echo "-- empty database" > "$dir/database.sql"
fi
echo "archiving files"
tar -cf "$dir/files.tar" -C "$data" . 2>/dev/null || tar -cf "$dir/files.tar" -T /dev/null
schema=0
[ -f "$data/.schema" ] && schema=$(cat "$data/.schema")
printf '{"message":"backup complete","schema_version":%s,"artifacts":[{"name":"database","path":"database.sql"},{"name":"files","path":"files.tar"}]}' \
"$schema" >&"${DEMO_RESULT_FD:-3}"
The manager creates and owns BACKUP_DIR; you write into it and report what you
produced, with checksums, so the backup manifest is self-describing and
verifiable without your tooling.
Restore is the mirror image, and by the time it runs the manager has already verified the backup, stopped every writer, and obtained a typed confirmation from the operator:
#!/bin/sh
# Restore hook. Destructive by nature: the manager has already verified the
# backup, stopped writers, and obtained an explicit confirmation before this
# runs.
set -eu
dir="${DEMO_BACKUP_DIR:?DEMO_BACKUP_DIR is required}"
data="${DEMO_DATA_DIR:?DEMO_DATA_DIR is required}"
mkdir -p "$data"
echo "restoring files"
[ -f "$dir/files.tar" ] && tar -xf "$dir/files.tar" -C "$data"
echo "restoring database"
[ -f "$dir/database.sql" ] && cp "$dir/database.sql" "$data/.schema"
printf '{"message":"restore complete"}' >&"${DEMO_RESULT_FD:-3}"
Health checks¶
health:
checks:
- {name: api, type: http, url: "http://127.0.0.1:18080/health/ready", timeout: 30s}
- {name: db, type: command, command: ["hooks/check-db"], timeout: 15s}
An http check runs from the host, so the port has to be published by your
Compose file. A command check is a hook, for anything HTTP cannot answer:
#!/bin/sh
# Health check the manager cannot make on its own: whether the schema is at
# the version the running release expects.
set -eu
marker="${DEMO_DATA_DIR:?DEMO_DATA_DIR is required}/.schema"
if [ ! -f "$marker" ]; then
echo "schema marker is missing" >&2
exit 1
fi
echo "schema at $(cat "$marker")"
The smoke test¶
Runs after health passes, and answers a different question: not "is it up" but "is it behaving".
#!/bin/sh
# Smoke test: verifies the product is not merely up but behaving.
set -eu
test -f "${DEMO_CONFIG_FILE:?DEMO_CONFIG_FILE is required}" || {
echo "configuration file is missing" >&2
exit 1
}
test -r "${DEMO_SECRETS_DIR:?DEMO_SECRETS_DIR is required}/db_password" || {
echo "db_password was not rendered" >&2
exit 1
}
printf '{"message":"smoke test passed"}' >&"${DEMO_RESULT_FD:-3}"
echo "smoke test passed"
A smoke-test failure fails the apply, and on an update that means the release
pointer goes back. Make it check something that would actually be broken by a
bad deployment — a rendered config that is missing, a secret that did not
render — rather than something that is true whenever the process started.
Timeouts and signals¶
Timeouts come from your manifest. On expiry the manager sends SIGTERM to the
process group, then SIGKILL.
The group, not the process, and that matters to you: a shell script has almost certainly started children, and signalling only the shell would leave them running against a database the manager is about to report as untouched.
Testing your hooks¶
They are executables with a documented environment, so they test like executables:
DEMO_DATA_DIR=$(mktemp -d) \
DEMO_RESULT_FD=1 \
./hooks/migrate
Setting DEMO_RESULT_FD=1 puts the structured result on stdout where you can
read it. In production it is 3, precisely so it is not mixed into the log.