Files
fisher fc40facfc5 Follow the house shell conventions
Constants UPPER_SNAKE, mutable locals PascalCase, braced expansions
throughout, per Development/Coding conventions in the vault.
2026-08-23 08:57:15 +00:00

373 lines
14 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# Generic, host/repo/branch-aware deployment.
#
# One script for every project and every environment in the fleet. It is driven
# entirely by targets.json, so onboarding a new environment never means writing
# another copy of this logic inside a project repository.
#
# Invoked by the webhook daemon as:
# cd-deploy --repo OWNER/NAME --ref refs/heads/BRANCH --sha FULLSHA [--event push]
#
# Deliberate properties, each one a lesson from the scripts this replaces:
#
# * It never touches git. The compose stacks live in uas-ng and are refreshed
# by that repo's own updater timer. A deploy script that runs `git reset
# --hard` owns two jobs badly instead of one job well.
# * It deploys the immutable <branch>-sha-<short> tag, then re-pins to the
# resolved digest. Mutable tags like :latest and :test have already drifted
# once in this fleet and served a broken build.
# * It verifies the image's org.opencontainers.image.revision label equals the
# commit that triggered the deploy, before changing anything.
# * It never runs `docker compose down`, and most emphatically never
# `down -v` -- `up -d` recreates exactly the services whose image changed.
# * A target belonging to another host is a clean no-op, logged as such, and
# never reported as a success.
set -Eeuo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly CD_TARGET="${SCRIPT_DIR}/cd-target"
readonly STATE_DIR="${XDG_STATE_HOME:-${HOME}/.local/state}/cd-webhook"
readonly NO_MATCH=3
REPO=""
REF=""
SHA=""
EVENT="push"
DRY_RUN=0
# ---------------------------------------------------------------- logging ---
log() { printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; }
warn() { log "WARN $*" >&2; }
die() { log "ERROR $*" >&2; exit 1; }
usage() {
sed -n '3,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
# ------------------------------------------------------------ arg parsing ---
while [[ $# -gt 0 ]]; do
case "$1" in
--repo) REPO="${2:?--repo needs a value}"; shift 2 ;;
--ref) REF="${2:?--ref needs a value}"; shift 2 ;;
--sha) SHA="${2:?--sha needs a value}"; shift 2 ;;
--event) EVENT="${2:?--event needs a value}"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage 0 ;;
*) die "unknown argument: $1 (try --help)" ;;
esac
done
[[ -n "${REPO}" ]] || die "--repo is required"
[[ -n "${REF}" ]] || die "--ref is required"
[[ -n "${SHA}" ]] || die "--sha is required"
if [[ "${EVENT}" != "push" ]]; then
log "ignoring event '${EVENT}' for ${REPO} (only 'push' deploys)"
exit 0
fi
if [[ "${REF}" != refs/heads/* ]]; then
log "ignoring non-branch ref '${REF}' for ${REPO} (tags and deletes never deploy)"
exit 0
fi
BRANCH="${REF#refs/heads/}"
if [[ ! "${SHA}" =~ ^[0-9a-f]{40}$ ]]; then
die "--sha must be a full 40-character hex commit, got: ${SHA}"
fi
# The all-zero SHA is how git spells "this ref was deleted".
if [[ "${SHA}" == "0000000000000000000000000000000000000000" ]]; then
log "ignoring branch deletion of ${REPO}@${BRANCH}"
exit 0
fi
# ------------------------------------------------------ target resolution ---
set +e
Resolved="$("${CD_TARGET}" resolve --repo "${REPO}" --branch "${BRANCH}")"
ResolveRc=$?
set -e
if [[ ${ResolveRc} -eq ${NO_MATCH} ]]; then
log "no target on $(hostname) for ${REPO}@${BRANCH} -- nothing to do here"
exit 0
elif [[ ${ResolveRc} -ne 0 ]]; then
die "target lookup failed for ${REPO}@${BRANCH} (exit ${ResolveRc})"
fi
eval "${Resolved}"
mkdir -p "${STATE_DIR}"
readonly LOG_FILE="${STATE_DIR}/${CD_NAME}.log"
readonly STATUS_FILE="${STATE_DIR}/${CD_NAME}.status"
exec > >(stdbuf -oL tee -a "${LOG_FILE}") 2>&1
log "=============================================================="
log "target ${CD_NAME} (${CD_ENV} on ${CD_RESOLVED_HOST})"
log "repo ${REPO}@${BRANCH}"
log "commit ${SHA}"
log "stack ${CD_STACK_DIR}/${CD_COMPOSE_FILE} [project ${CD_COMPOSE_PROJECT}]"
[[ ${DRY_RUN} -eq 1 ]] && log "mode DRY RUN -- nothing will be changed"
[[ -d "${CD_STACK_DIR}" ]] || die "stack directory missing: ${CD_STACK_DIR}"
[[ -f "${CD_STACK_DIR}/${CD_COMPOSE_FILE}" ]] \
|| die "compose file missing: ${CD_STACK_DIR}/${CD_COMPOSE_FILE}"
# ----------------------------------------------------------------- notify ---
notify() {
local Status="$1" Message="$2" Icon Topic
case "${Status}" in
ok) Icon="✅" ;;
fail) Icon="❌" ;;
*) Icon="️" ;;
esac
# Every terminal outcome notifies, so this is also where the machine-readable
# record of that outcome belongs. cd-status reads this file rather than
# grepping the log's prose, which quietly reported "no completed deploy"
# for a healthy target whenever a log line was reworded.
printf '%s\t%s\t%s\t%s\n' \
"$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "${Status}" "${SHA:0:12}" "${Message}" \
> "${STATUS_FILE}"
Topic="${REPO//\//-}"
curl -fsS --max-time 10 \
-H "Title: ${CD_NAME} deploy" \
-d "${Icon} ${CD_NAME} (${CD_ENV}): ${Message}" \
"${CD_NTFY_BASE_URL}/${Topic}" >/dev/null 2>&1 \
|| warn "ntfy notification failed (the deploy itself is unaffected)"
}
# ------------------------------------------------------------------- lock ---
# Serialise per target, not per host: a test deploy must not block a prod one.
readonly LOCK_FILE="${STATE_DIR}/${CD_NAME}.lock"
exec 9>"${LOCK_FILE}"
log "waiting for deploy lock (${CD_LOCK_WAIT_SECONDS}s max)"
if ! flock -w "${CD_LOCK_WAIT_SECONDS}" 9; then
notify fail "timed out waiting for the deploy lock after ${CD_LOCK_WAIT_SECONDS}s"
die "timed out waiting for deploy lock after ${CD_LOCK_WAIT_SECONDS}s"
fi
log "lock acquired"
# -------------------------------------------------------------- image tag ---
Tag="${CD_IMAGE_TAG_TEMPLATE}"
Tag="${Tag//\{branch\}/$BRANCH}"
Tag="${Tag//\{env\}/$CD_ENV}"
Tag="${Tag//\{sha\}/$SHA}"
Tag="${Tag//\{short7\}/${SHA:0:7}}"
Tag="${Tag//\{short12\}/${SHA:0:12}}"
readonly CANDIDATE="${CD_IMAGE_REPO}:${Tag}"
log "candidate ${CANDIDATE}"
# ------------------------------------------------------- wait for the image --
# The push webhook fires the moment the commit lands, which is well before CI
# has finished building. Poll rather than fail: the running container keeps
# serving throughout.
wait_for_image() {
local Elapsed=0
while true; do
if docker pull "${CANDIDATE}" >/dev/null 2>&1; then
log "image available after ${Elapsed}s"
return 0
fi
if (( Elapsed >= CD_IMAGE_WAIT_SECONDS )); then
return 1
fi
log "image not published yet, retrying in ${CD_IMAGE_POLL_INTERVAL}s (${Elapsed}s elapsed)"
sleep "${CD_IMAGE_POLL_INTERVAL}"
Elapsed=$(( Elapsed + CD_IMAGE_POLL_INTERVAL ))
done
}
log "waiting for CI to publish the image (up to ${CD_IMAGE_WAIT_SECONDS}s)"
if [[ ${DRY_RUN} -eq 1 ]]; then
log "dry run: skipping image wait"
elif ! wait_for_image; then
notify fail "image ${CANDIDATE} never appeared (waited ${CD_IMAGE_WAIT_SECONDS}s) -- did CI fail?"
die "image ${CANDIDATE} did not appear within ${CD_IMAGE_WAIT_SECONDS}s"
fi
# --------------------------------------------------- verify and pin digest ---
image_digest_ref() {
docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$1" 2>/dev/null \
| grep "^${CD_IMAGE_REPO}@" | head -n1
}
if [[ ${DRY_RUN} -eq 1 ]]; then
DEPLOY_IMAGE="${CANDIDATE}"
log "dry run: would deploy ${DEPLOY_IMAGE}"
else
Revision="$(docker image inspect \
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \
"${CANDIDATE}" 2>/dev/null || true)"
if [[ -z "${Revision}" || "${Revision}" == "<no value>" ]]; then
warn "image carries no org.opencontainers.image.revision label; cannot prove provenance"
elif [[ "${Revision}" != "${SHA}" ]]; then
notify fail "image ${CANDIDATE} is built from ${Revision:0:12}, not ${SHA:0:12} -- refusing to deploy"
die "provenance mismatch: ${CANDIDATE} declares revision ${Revision}, expected ${SHA}"
else
log "provenance revision label matches ${SHA:0:12}"
fi
DEPLOY_IMAGE="$(image_digest_ref "${CANDIDATE}")"
if [[ -z "${DEPLOY_IMAGE}" ]]; then
warn "could not resolve a digest for ${CANDIDATE}; deploying by tag instead"
DEPLOY_IMAGE="${CANDIDATE}"
else
log "pinned ${DEPLOY_IMAGE}"
fi
fi
readonly DEPLOY_IMAGE
# --------------------------------------------- remember what is running now ---
RunningImageId="$(docker inspect --format '{{.Image}}' "${CD_CONTAINER}" 2>/dev/null || true)"
PREVIOUS_IMAGE=""
PREVIOUS_REVISION=""
if [[ -n "${RunningImageId}" ]]; then
PREVIOUS_IMAGE="$(image_digest_ref "${RunningImageId}")"
PREVIOUS_REVISION="$(docker image inspect \
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \
"${RunningImageId}" 2>/dev/null || true)"
fi
readonly PREVIOUS_IMAGE PREVIOUS_REVISION
if [[ -n "${PREVIOUS_IMAGE}" ]]; then
log "current ${PREVIOUS_IMAGE} (revision ${PREVIOUS_REVISION:0:12})"
else
log "current (nothing running -- first deploy, or container absent)"
fi
# ----------------------------------------------------------------- deploy ---
compose_up() {
local Image="$1"
# Matches the manual runbook in the vault's "DomainDingo Development" note:
# recreate exactly one service, leave its dependencies alone, and never run
# `docker compose down` -- that causes avoidable downtime, and `down -v`
# would destroy the bind-mounted data these stacks depend on.
#
# `--pull always` is deliberately absent: the image is already pinned to a
# digest and pulled, so there is nothing left to resolve.
( cd "${CD_STACK_DIR}" \
&& env "${CD_IMAGE_ENV_VAR}=${Image}" \
"${CD_PULL_POLICY_ENV_VAR}=never" \
timeout "${CD_COMPOSE_TIMEOUT_SECONDS}" \
docker compose -f "${CD_COMPOSE_FILE}" -p "${CD_COMPOSE_PROJECT}" \
up -d --no-build --no-deps --force-recreate "${CD_COMPOSE_SERVICE}" )
}
compose_validate() {
local Image="$1"
( cd "${CD_STACK_DIR}" \
&& env "${CD_IMAGE_ENV_VAR}=${Image}" \
"${CD_PULL_POLICY_ENV_VAR}=never" \
docker compose -f "${CD_COMPOSE_FILE}" -p "${CD_COMPOSE_PROJECT}" \
config --quiet )
}
# Write the rollback record where the manual runbook looks for it, in the same
# format, so an operator following that note can recover from an automated
# deploy without knowing this tool exists.
write_rollback_record() {
local Image="$1" Revision="$2" Dir="${CD_STACK_DIR}/${CD_ROLLBACK_RECORD_DIR}"
local Record="${Dir}/${CD_COMPOSE_SERVICE}-rollback-$(date -u +%Y%m%d-%H%M%S).env"
[[ -d "${Dir}" ]] || { warn "no rollback record directory at ${Dir}"; return 0; }
( umask 077
printf 'DD_ROLLBACK_IMAGE=%s\nDD_ROLLBACK_REVISION=%s\nDD_ROLLBACK_CAPTURED_AT=%s\n' \
"${Image}" "${Revision}" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "${Record}" )
log "rollback recorded at ${Record}"
}
check_health() {
local Attempt=1 Body
while (( Attempt <= CD_HEALTH_RETRIES )); do
Body="$(curl -fsS --max-time 5 "${CD_HEALTH_URL}" 2>/dev/null || true)"
if [[ -n "${Body}" ]]; then
if python3 -c '
import json, sys
key, want = sys.argv[1], sys.argv[2]
try:
data = json.loads(sys.stdin.read())
except Exception:
sys.exit(1)
sys.exit(0 if str(data.get(key, "")).lower() == want.lower() else 1)
' "${CD_HEALTH_EXPECT_KEY}" "${CD_HEALTH_EXPECT_VALUE}" <<<"${Body}"; then
log "health ok after ${Attempt} attempt(s): ${Body}"
return 0
fi
fi
log "health not ready (attempt ${Attempt}/${CD_HEALTH_RETRIES}), retrying in ${CD_HEALTH_INTERVAL}s"
sleep "${CD_HEALTH_INTERVAL}"
(( Attempt++ ))
done
warn "health check never passed: last response was '${Body:-<no response>}'"
return 1
}
if [[ ${DRY_RUN} -eq 1 ]]; then
log "dry run: would recreate service ${CD_COMPOSE_SERVICE} with ${CD_IMAGE_ENV_VAR}=${DEPLOY_IMAGE}"
log "dry run: would health-check ${CD_HEALTH_URL}"
log "dry run complete -- no changes made"
exit 0
fi
# Catch a broken compose file or a bad interpolation before the running
# container is touched.
if ! compose_validate "${DEPLOY_IMAGE}"; then
notify fail "compose config is invalid for ${SHA:0:12} -- nothing was changed"
die "docker compose config failed; the running stack was not touched"
fi
[[ -n "${PREVIOUS_IMAGE}" ]] && write_rollback_record "${PREVIOUS_IMAGE}" "${PREVIOUS_REVISION}"
log "deploying ${DEPLOY_IMAGE}"
if ! compose_up "${DEPLOY_IMAGE}"; then
notify fail "docker compose up failed for ${SHA:0:12} -- stack left as-is"
die "docker compose up failed"
fi
if check_health; then
log "=== deploy succeeded: ${CD_NAME} now runs ${SHA:0:12} ==="
notify ok "deployed ${SHA:0:12} (${DEPLOY_IMAGE##*@})"
exit 0
fi
# ---------------------------------------------------------------- rollback ---
if [[ "${CD_ROLLBACK_ON_FAILURE}" != "true" ]]; then
notify fail "health check failed for ${SHA:0:12}; rollback disabled, stack left on the new image"
die "health check failed and rollback is disabled for this target"
fi
if [[ -z "${PREVIOUS_IMAGE}" ]]; then
notify fail "health check failed for ${SHA:0:12} and there is no previous image to roll back to"
die "health check failed; no previous image recorded, leaving the stack as it is"
fi
warn "health check failed -- rolling back to ${PREVIOUS_IMAGE}"
if compose_up "${PREVIOUS_IMAGE}" && check_health; then
notify fail "deploy of ${SHA:0:12} failed health check; rolled back to the previous image successfully"
die "deploy failed health check; rolled back to ${PREVIOUS_IMAGE}"
fi
notify fail "deploy of ${SHA:0:12} failed AND rollback failed -- ${CD_NAME} needs manual attention now"
die "deploy failed and rollback also failed; manual intervention required"