View raw

1 #!/usr/bin/env bash 2 # 3 # hito post-receive hook. Runs as the git user after every push to the bare 4 # repo. The git user owns the bare repo and /srv/hito (except data/, which 5 # belongs to the hito runtime user), so this 6 # hook builds and publishes directly, with no privilege hop. The only 7 # privileged action is restarting the service, through one scoped sudoers rule. 8 # 9 # For an accepted push to master: check out the verified revision into a fresh 10 # timestamped release, build it against the shared opam switch, flip 11 # /srv/hito/current, restart the service, and confirm it answers HTTP. The 12 # pre-receive hook rejects a failed master verification. Any build failure 13 # leaves the live release untouched. A failed health check restores the 14 # previous release. 15 # 16 # Style: Google Shell Style Guide. 17 18 set -euo pipefail 19 20 readonly DEPLOY_BRANCH="master" 21 readonly SRV="/srv/hito" 22 readonly BARE="/srv/git/hito.git" 23 readonly SHARED="${SRV}/shared" 24 readonly RELEASES="${SRV}/releases" 25 readonly KEEP=5 26 readonly HEALTH_URL="http://127.0.0.1:8080/login" 27 readonly HEALTH_TRIES=10 28 readonly HEALTH_DELAY=1 29 30 export OPAMROOT="/srv/git/.opam" 31 export OPAMSWITCH="${SHARED}" 32 33 err() { 34 printf ' hito-deploy: %s\n' "$*" >&2 35 } 36 37 fail() { 38 err "FAILED: $*" 39 exit 1 40 } 41 42 # Poll the app until it answers, or fail after HEALTH_TRIES attempts. 43 health_ok() { 44 local i 45 for ((i = 0; i < HEALTH_TRIES; i++)); do 46 if curl -sf -o /dev/null --max-time 3 "${HEALTH_URL}"; then 47 return 0 48 fi 49 sleep "${HEALTH_DELAY}" 50 done 51 return 1 52 } 53 54 # Materialize the pushed revision from the bare repo into ${rel}. git archive 55 # yields exactly the tracked tree, never a stray shared/ or releases/ directory; 56 # those are strict siblings of a release and must never appear inside one. 57 checkout_release() { 58 local sha="$1" rel="$2" 59 git --git-dir="${BARE}" archive "${sha}" | tar -x -C "${rel}" 60 } 61 62 # Build the verified release artifact against the shared switch. The full log 63 # stays inside the release so a successful deploy leaves a record, not only a 64 # failed one. 65 build_release() { 66 local rel="$1" 67 local buildlog="${rel}/deploy.log" 68 eval "$(opam env --switch="${SHARED}" --set-switch)" 69 cd "${rel}" 70 71 err "dune build (release)" 72 dune build >"${buildlog}" 2>&1 \ 73 || { tail -40 "${buildlog}" >&2; return 1; } 74 } 75 76 # Write per-release provenance and append one durable history line. 77 record_metadata() { 78 local sha="$1" rel="$2" ts="$3" build_secs="$4" 79 local subject version 80 subject="$(git --git-dir="${BARE}" log -1 --format=%s "${sha}")" 81 version="$(git --git-dir="${BARE}" log -1 --format=%cd \ 82 --date=format:%Y%m%d "${sha}")" 83 84 jq -n \ 85 --arg commit "${sha}" \ 86 --arg subject "${subject}" \ 87 --arg branch "${DEPLOY_BRANCH}" \ 88 --arg deployed_at "${ts}" \ 89 --argjson build_seconds "${build_secs}" \ 90 --arg release "${rel}" \ 91 '{commit: $commit, subject: $subject, branch: $branch, 92 deployed_at: $deployed_at, build_seconds: $build_seconds, 93 release: $release}' \ 94 >"${rel}/deploy-info.json" 95 96 printf '%s\t%s\t%ss\t%s\t%s\n' \ 97 "${ts}" "${sha:0:12}" "${build_secs}" "${DEPLOY_BRANCH}" "${subject}" \ 98 >>"${SHARED}/deploy-history.tsv" 99 printf 'HITO_VERSION=%s\n' "${version}" >"${SHARED}/deploy.env" 100 } 101 102 # Point current at ${rel} in a single rename so no request sees a half state. 103 flip_current() { 104 local rel="$1" 105 ln -sfn "${rel}" "${SRV}/current.tmp" 106 mv -T "${SRV}/current.tmp" "${SRV}/current" 107 } 108 109 # Restart, health-check, and roll back to ${prev} if the new release is unwell. 110 restart_and_verify() { 111 local prev="$1" 112 sudo -n systemctl restart hito || fail "restart" 113 if health_ok; then 114 err "healthy: ${HEALTH_URL}" 115 return 0 116 fi 117 if [[ -n "${prev}" && -d "${prev}" ]]; then 118 err "health check failed; rolling back to ${prev}" 119 flip_current "${prev}" 120 sudo -n systemctl restart hito || true 121 fail "health check (rolled back to previous release)" 122 fi 123 fail "health check (no previous release to roll back to)" 124 } 125 126 # Remove all but the most recent KEEP releases. 127 prune_releases() { 128 ls -1dt "${RELEASES}"/*/ 2>/dev/null | tail -n +$((KEEP + 1)) \ 129 | xargs -r rm -rf 130 } 131 132 deploy_ref() { 133 local sha="$1" 134 local start="${SECONDS}" 135 local ts rel prev build_secs 136 ts="$(date -u +"%Y-%m-%dT%H-%M-%SZ")" 137 rel="${RELEASES}/${ts}" 138 prev="$(readlink -f "${SRV}/current" 2>/dev/null || true)" 139 140 err "deploying ${sha:0:12} to ${rel}" 141 mkdir -p "${rel}" 142 checkout_release "${sha}" "${rel}" || { rm -rf "${rel}"; fail "checkout"; } 143 build_release "${rel}" || { rm -rf "${rel}"; fail "build"; } 144 build_secs=$((SECONDS - start)) 145 146 ln -sfn "${SRV}/data/hito.sqlite" "${rel}/hito.sqlite" 147 record_metadata "${sha}" "${rel}" "${ts}" "${build_secs}" 148 flip_current "${rel}" 149 err "current -> ${rel}" 150 restart_and_verify "${prev}" 151 err "deployed ${sha:0:12} in ${build_secs}s" 152 prune_releases 153 err "done" 154 } 155 156 main() { 157 local _old new ref 158 while read -r _old new ref; do 159 if [[ "${ref}" != "refs/heads/${DEPLOY_BRANCH}" ]]; then 160 err "skipping ${ref} (only ${DEPLOY_BRANCH} deploys)" 161 continue 162 fi 163 deploy_ref "${new}" 164 done 165 } 166 167 main "$@" 168