View raw

1 #!/usr/bin/env bash 2 # 3 # hito deployment setup. One root script, three subcommands. 4 # 5 # sudo bash setup.sh install One-time host setup: create the git-owned 6 # /srv/hito layout, the hito runtime user and 7 # its data/ directory, build the shared switch as 8 # git, install the receive hooks, the scoped sudoers 9 # rule, and the systemd unit from hito.service beside 10 # this script. Idempotent. Creates no release; 11 # the first push (or 'deploy') does. 12 # sudo bash setup.sh deploy Reinstall hito.service if it changed, build 13 # master as a fresh release on the shared 14 # switch, and flip current. Health-checks and 15 # rolls back on failure. Use to go live without a 16 # push, or right after 'install'. 17 # sudo bash setup.sh reset Wipe /srv/hito except data/, then rebuild the 18 # switch as git and 'deploy'. Destructive: all 19 # else, including the switch, is removed. 20 # 21 # Two-account model. The git user owns the bare repo and /srv/hito (except 22 # data/), builds every release, and may run one privileged command, 23 # 'systemctl restart hito', through a scoped sudoers rule. The hito system user 24 # runs the service and owns only /srv/hito/data, which holds the database. A 25 # compromised service cannot rewrite releases or the deploy pipeline, and git 26 # cannot read the database. The receive hooks enforce the master check before 27 # the post-receive hook deploys each accepted push. 28 # 29 # Style: Google Shell Style Guide. 30 31 set -euo pipefail 32 33 readonly SVC_USER="git" 34 readonly SVC_GROUP="git" 35 readonly RUN_USER="hito" 36 readonly RUN_GROUP="hito" 37 readonly SRV="/srv/hito" 38 readonly BARE="/srv/git/hito.git" 39 readonly UNIT="/etc/systemd/system/hito.service" 40 readonly SUDOERS="/etc/sudoers.d/hito-deploy" 41 readonly SHARED="${SRV}/shared" 42 readonly RELEASES="${SRV}/releases" 43 readonly DATA="${SRV}/data" 44 readonly DB="${DATA}/hito.sqlite" 45 readonly SWITCH="${SHARED}/_opam" 46 readonly OCAML_COMPILER="ocaml-base-compiler.5.5.0" 47 readonly HEALTH_URL="http://127.0.0.1:8080/login" 48 readonly OPAMROOT_DIR="/srv/git/.opam" 49 HERE="$(cd "$(dirname "$0")" && pwd)" 50 readonly HERE 51 52 require_root() { 53 if [[ "$(id -u)" -ne 0 ]]; then 54 echo "run as root" >&2 55 exit 1 56 fi 57 } 58 59 # Run a command as the service user with the git-owned opam root exported. 60 # git's login shell is git-shell, so invoke bash explicitly rather than -H login. 61 as_svc() { 62 sudo -u "${SVC_USER}" env "OPAMROOT=${OPAMROOT_DIR}" "$@" 63 } 64 65 ensure_switch() { 66 if [[ -d "${SWITCH}" ]]; then 67 echo " shared switch present" 68 return 0 69 fi 70 # A fresh, git-owned opam root must be initialized before a switch can be 71 # created. --bare creates the root and default repository without a switch. 72 # --disable-sandboxing: this host has no bubblewrap, so opam's sandbox would 73 # fail; the build already runs as an unprivileged, filesystem-scoped user. 74 if [[ ! -d "${OPAMROOT_DIR}/repo/default" ]]; then 75 echo " initializing git-owned opam root at ${OPAMROOT_DIR}" 76 as_svc opam init --bare --disable-sandboxing --no-setup -y 77 fi 78 echo " creating shared switch (${OCAML_COMPILER}) as ${SVC_USER}" 79 as_svc opam switch create "${SHARED}" "${OCAML_COMPILER}" --no-install -y 80 } 81 82 install_hooks() { 83 install -o "${SVC_USER}" -g "${SVC_GROUP}" -m 0755 \ 84 "${HERE}/pre-receive" "${BARE}/hooks/pre-receive" 85 install -o "${SVC_USER}" -g "${SVC_GROUP}" -m 0755 \ 86 "${HERE}/post-receive" "${BARE}/hooks/post-receive" 87 install -o "${SVC_USER}" -g "${SVC_GROUP}" -m 0755 \ 88 "${HERE}/../scripts/ci.sh" "${BARE}/hooks/hito-ci" 89 echo " installed receive hooks and trusted CI helper" 90 } 91 92 # Create the unprivileged runtime account once. No login shell, no home. 93 ensure_run_user() { 94 if id -u "${RUN_USER}" >/dev/null 2>&1; then 95 echo " user ${RUN_USER} present" 96 return 0 97 fi 98 useradd --system --user-group \ 99 --home-dir "${DATA}" --no-create-home \ 100 --shell /usr/sbin/nologin "${RUN_USER}" 101 echo " created system user ${RUN_USER}" 102 } 103 104 # Create data/, owned by the runtime user and private to it. The service 105 # creates the database inside it under UMask=0077. 106 ensure_data() { 107 install -d -o "${RUN_USER}" -g "${RUN_GROUP}" -m 0700 "${DATA}" 108 } 109 110 install_sudoers() { 111 # The one privileged action in the whole pipeline: git may restart the service. 112 cat >"${SUDOERS}" <<SUDO 113 ${SVC_USER} ALL=(root) NOPASSWD: /usr/bin/systemctl restart hito 114 SUDO 115 chmod 0440 "${SUDOERS}" 116 visudo -c -f "${SUDOERS}" >/dev/null && echo " sudoers valid" 117 } 118 119 # Install the unit shipped beside this script. The repo copy is the source of 120 # truth; git history keeps earlier versions. 121 install_unit() { 122 local src="${HERE}/hito.service" 123 if cmp -s "${src}" "${UNIT}"; then 124 echo " unit unchanged" 125 return 0 126 fi 127 systemd-analyze verify "${src}" 128 install -o root -g root -m 0644 "${src}" "${UNIT}" 129 systemctl daemon-reload 130 systemctl enable hito >/dev/null 2>&1 131 echo " installed ${UNIT}" 132 } 133 134 health_ok() { 135 local i 136 for ((i = 0; i < 10; i++)); do 137 if curl -sf -o /dev/null --max-time 3 "${HEALTH_URL}"; then 138 return 0 139 fi 140 sleep 1 141 done 142 return 1 143 } 144 145 # Build master in a staging dir OUTSIDE /srv/hito, so dune's project-root search 146 # never sees current or a sibling release, then publish it, flip current, 147 # restart, health-check, and roll back on failure. Records deploy metadata. 148 # Runs entirely as the service user; root only orchestrates. 149 build_and_flip() { 150 local start="${SECONDS}" 151 local ts rel stage sha subject version build_secs prev 152 ts="$(date -u +"%Y-%m-%dT%H-%M-%SZ")" 153 rel="${RELEASES}/${ts}" 154 prev="$(readlink -f "${SRV}/current" 2>/dev/null || true)" 155 sha="$(git --git-dir="${BARE}" rev-parse "refs/heads/master")" 156 stage="$(as_svc mktemp -d /tmp/hito-stage.XXXXXX)" 157 158 echo " building master ${sha:0:12} as ${SVC_USER}" 159 as_svc bash -c " 160 set -euo pipefail 161 git --git-dir='${BARE}' archive '${sha}' | tar -x -C '${stage}' 162 cd '${stage}' 163 eval \"\$(opam env --switch='${SHARED}' --set-switch)\" 164 opam install . --deps-only --with-test --with-dev-setup -y \ 165 2>&1 | tee deploy.log 166 BUILD_DIR=_build-ci sh '${HERE}/../scripts/ci.sh' 2>&1 | tee -a deploy.log 167 dune build 2>&1 | tee -a deploy.log 168 " 169 build_secs=$((SECONDS - start)) 170 171 mkdir -p "${RELEASES}" 172 chown "${SVC_USER}:${SVC_GROUP}" "${RELEASES}" 173 mv "${stage}" "${rel}" 174 # mktemp -d creates 0700; the runtime user must be able to enter the release. 175 chmod 0755 "${rel}" 176 ln -sfn "${DB}" "${rel}/hito.sqlite" 177 178 subject="$(git --git-dir="${BARE}" log -1 --format=%s "${sha}")" 179 version="$(git --git-dir="${BARE}" log -1 --format=%cd \ 180 --date=format:%Y%m%d "${sha}")" 181 jq -n \ 182 --arg commit "${sha}" \ 183 --arg subject "${subject}" \ 184 --arg branch "master" \ 185 --arg deployed_at "${ts}" \ 186 --argjson build_seconds "${build_secs}" \ 187 --arg release "${rel}" \ 188 '{commit: $commit, subject: $subject, branch: $branch, 189 deployed_at: $deployed_at, build_seconds: $build_seconds, 190 release: $release}' \ 191 >"${rel}/deploy-info.json" 192 printf '%s\t%s\t%ss\t%s\t%s\n' \ 193 "${ts}" "${sha:0:12}" "${build_secs}" "master" "${subject}" \ 194 >>"${SHARED}/deploy-history.tsv" 195 printf 'HITO_VERSION=%s\n' "${version}" >"${SHARED}/deploy.env" 196 197 chown -R "${SVC_USER}:${SVC_GROUP}" "${rel}" 198 chown "${SVC_USER}:${SVC_GROUP}" "${SHARED}/deploy-history.tsv" "${SHARED}/deploy.env" 199 ln -sfn "${rel}" "${SRV}/current.tmp" 200 mv -T "${SRV}/current.tmp" "${SRV}/current" 201 chown -h "${SVC_USER}:${SVC_GROUP}" "${SRV}/current" 202 echo " current -> ${rel} (built in ${build_secs}s)" 203 204 systemctl restart hito 205 if health_ok; then 206 echo " healthy at ${HEALTH_URL}" 207 return 0 208 fi 209 if [[ -n "${prev}" && -d "${prev}" ]]; then 210 echo " health check failed; rolling back to ${prev}" 211 ln -sfn "${prev}" "${SRV}/current.tmp" 212 mv -T "${SRV}/current.tmp" "${SRV}/current" 213 systemctl restart hito || true 214 echo " rolled back; recent journal:" >&2 215 journalctl -u hito -n 15 --no-pager >&2 216 exit 1 217 fi 218 echo " health check failed and no previous release; journal:" >&2 219 journalctl -u hito -n 15 --no-pager >&2 220 exit 1 221 } 222 223 cmd_install() { 224 echo "== ${SVC_USER}-owned layout ==" 225 install -d -o "${SVC_USER}" -g "${SVC_GROUP}" -m 0755 \ 226 "${SRV}" "${SHARED}" "${RELEASES}" 227 echo "== runtime user and data ==" 228 ensure_run_user 229 ensure_data 230 echo "== git-owned opam root ==" 231 mkdir -p "${OPAMROOT_DIR}" 232 chown "${SVC_USER}:${SVC_GROUP}" "${OPAMROOT_DIR}" 233 echo "== shared opam switch =="; ensure_switch 234 echo "== receive hooks =="; install_hooks 235 echo "== scoped sudoers =="; install_sudoers 236 echo "== systemd unit =="; install_unit 237 echo 238 echo "Done. Run 'sudo bash setup.sh deploy' to build the first release," 239 echo "or push master to trigger the hook." 240 } 241 242 cmd_deploy() { 243 if [[ ! -d "${SWITCH}" ]]; then 244 echo "FATAL: no shared switch; run 'install' first" >&2 245 exit 1 246 fi 247 echo "== systemd unit =="; install_unit 248 echo "== deploy master =="; build_and_flip 249 } 250 251 cmd_reset() { 252 if [[ ! -f "${DB}" ]]; then 253 echo "FATAL: no database at ${DB}; aborting before wipe" >&2 254 exit 1 255 fi 256 local stash 257 echo "== stop service =="; systemctl stop hito || true 258 echo "== stash the data directory ==" 259 stash="$(mktemp -d /root/hito-reset-stash.XXXXXX)" 260 cp -a "${DATA}/." "${stash}/" # safety copy, never removed by us 261 echo " DB backup at ${stash}/hito.sqlite" 262 echo "== wipe /srv/hito contents except data/ ==" 263 find "${SRV}" -mindepth 1 -maxdepth 1 ! -path "${DATA}" -exec rm -rf {} + 264 echo "== rebuild skeleton, ${SVC_USER}-owned ==" 265 mkdir -p "${SHARED}" "${RELEASES}" 266 chown "${SVC_USER}:${SVC_GROUP}" "${SRV}" "${SHARED}" "${RELEASES}" 267 mkdir -p "${OPAMROOT_DIR}" 268 chown "${SVC_USER}:${SVC_GROUP}" "${OPAMROOT_DIR}" 269 ensure_switch 270 echo "== deploy master =="; build_and_flip 271 echo 272 echo "Done. Safety DB backup remains at ${stash}/hito.sqlite (remove when confident)." 273 } 274 275 main() { 276 require_root 277 # Run from a directory that no subcommand deletes. reset wipes /srv/hito, so a 278 # caller sitting in /srv/hito/... would otherwise leave child processes with a 279 # nonexistent working directory. 280 cd / 281 case "${1:-}" in 282 install) cmd_install ;; 283 deploy) cmd_deploy ;; 284 reset) cmd_reset ;; 285 *) 286 echo "usage: sudo bash setup.sh {install|deploy|reset}" >&2 287 exit 2 288 ;; 289 esac 290 } 291 292 main "$@" 293