#!/usr/bin/env bash # # hito deployment setup. One root script, three subcommands. # # sudo bash setup.sh install One-time host setup: create the git-owned # /srv/hito layout, the hito runtime user and # its data/ directory, build the shared switch as # git, install the receive hooks, the scoped sudoers # rule, and the systemd unit from hito.service beside # this script. Idempotent. Creates no release; # the first push (or 'deploy') does. # sudo bash setup.sh deploy Reinstall hito.service if it changed, build # master as a fresh release on the shared # switch, and flip current. Health-checks and # rolls back on failure. Use to go live without a # push, or right after 'install'. # sudo bash setup.sh reset Wipe /srv/hito except data/, then rebuild the # switch as git and 'deploy'. Destructive: all # else, including the switch, is removed. # # Two-account model. The git user owns the bare repo and /srv/hito (except # data/), builds every release, and may run one privileged command, # 'systemctl restart hito', through a scoped sudoers rule. The hito system user # runs the service and owns only /srv/hito/data, which holds the database. A # compromised service cannot rewrite releases or the deploy pipeline, and git # cannot read the database. The receive hooks enforce the master check before # the post-receive hook deploys each accepted push. # # Style: Google Shell Style Guide. set -euo pipefail readonly SVC_USER="git" readonly SVC_GROUP="git" readonly RUN_USER="hito" readonly RUN_GROUP="hito" readonly SRV="/srv/hito" readonly BARE="/srv/git/hito.git" readonly UNIT="/etc/systemd/system/hito.service" readonly SUDOERS="/etc/sudoers.d/hito-deploy" readonly SHARED="${SRV}/shared" readonly RELEASES="${SRV}/releases" readonly DATA="${SRV}/data" readonly DB="${DATA}/hito.sqlite" readonly SWITCH="${SHARED}/_opam" readonly OCAML_COMPILER="ocaml-base-compiler.5.5.0" readonly HEALTH_URL="http://127.0.0.1:8080/login" readonly OPAMROOT_DIR="/srv/git/.opam" HERE="$(cd "$(dirname "$0")" && pwd)" readonly HERE require_root() { if [[ "$(id -u)" -ne 0 ]]; then echo "run as root" >&2 exit 1 fi } # Run a command as the service user with the git-owned opam root exported. # git's login shell is git-shell, so invoke bash explicitly rather than -H login. as_svc() { sudo -u "${SVC_USER}" env "OPAMROOT=${OPAMROOT_DIR}" "$@" } ensure_switch() { if [[ -d "${SWITCH}" ]]; then echo " shared switch present" return 0 fi # A fresh, git-owned opam root must be initialized before a switch can be # created. --bare creates the root and default repository without a switch. # --disable-sandboxing: this host has no bubblewrap, so opam's sandbox would # fail; the build already runs as an unprivileged, filesystem-scoped user. if [[ ! -d "${OPAMROOT_DIR}/repo/default" ]]; then echo " initializing git-owned opam root at ${OPAMROOT_DIR}" as_svc opam init --bare --disable-sandboxing --no-setup -y fi echo " creating shared switch (${OCAML_COMPILER}) as ${SVC_USER}" as_svc opam switch create "${SHARED}" "${OCAML_COMPILER}" --no-install -y } install_hooks() { install -o "${SVC_USER}" -g "${SVC_GROUP}" -m 0755 \ "${HERE}/pre-receive" "${BARE}/hooks/pre-receive" install -o "${SVC_USER}" -g "${SVC_GROUP}" -m 0755 \ "${HERE}/post-receive" "${BARE}/hooks/post-receive" install -o "${SVC_USER}" -g "${SVC_GROUP}" -m 0755 \ "${HERE}/../scripts/ci.sh" "${BARE}/hooks/hito-ci" echo " installed receive hooks and trusted CI helper" } # Create the unprivileged runtime account once. No login shell, no home. ensure_run_user() { if id -u "${RUN_USER}" >/dev/null 2>&1; then echo " user ${RUN_USER} present" return 0 fi useradd --system --user-group \ --home-dir "${DATA}" --no-create-home \ --shell /usr/sbin/nologin "${RUN_USER}" echo " created system user ${RUN_USER}" } # Create data/, owned by the runtime user and private to it. The service # creates the database inside it under UMask=0077. ensure_data() { install -d -o "${RUN_USER}" -g "${RUN_GROUP}" -m 0700 "${DATA}" } install_sudoers() { # The one privileged action in the whole pipeline: git may restart the service. cat >"${SUDOERS}" </dev/null && echo " sudoers valid" } # Install the unit shipped beside this script. The repo copy is the source of # truth; git history keeps earlier versions. install_unit() { local src="${HERE}/hito.service" if cmp -s "${src}" "${UNIT}"; then echo " unit unchanged" return 0 fi systemd-analyze verify "${src}" install -o root -g root -m 0644 "${src}" "${UNIT}" systemctl daemon-reload systemctl enable hito >/dev/null 2>&1 echo " installed ${UNIT}" } health_ok() { local i for ((i = 0; i < 10; i++)); do if curl -sf -o /dev/null --max-time 3 "${HEALTH_URL}"; then return 0 fi sleep 1 done return 1 } # Build master in a staging dir OUTSIDE /srv/hito, so dune's project-root search # never sees current or a sibling release, then publish it, flip current, # restart, health-check, and roll back on failure. Records deploy metadata. # Runs entirely as the service user; root only orchestrates. build_and_flip() { local start="${SECONDS}" local ts rel stage sha subject version build_secs prev ts="$(date -u +"%Y-%m-%dT%H-%M-%SZ")" rel="${RELEASES}/${ts}" prev="$(readlink -f "${SRV}/current" 2>/dev/null || true)" sha="$(git --git-dir="${BARE}" rev-parse "refs/heads/master")" stage="$(as_svc mktemp -d /tmp/hito-stage.XXXXXX)" echo " building master ${sha:0:12} as ${SVC_USER}" as_svc bash -c " set -euo pipefail git --git-dir='${BARE}' archive '${sha}' | tar -x -C '${stage}' cd '${stage}' eval \"\$(opam env --switch='${SHARED}' --set-switch)\" opam install . --deps-only --with-test --with-dev-setup -y \ 2>&1 | tee deploy.log BUILD_DIR=_build-ci sh '${HERE}/../scripts/ci.sh' 2>&1 | tee -a deploy.log dune build 2>&1 | tee -a deploy.log " build_secs=$((SECONDS - start)) mkdir -p "${RELEASES}" chown "${SVC_USER}:${SVC_GROUP}" "${RELEASES}" mv "${stage}" "${rel}" # mktemp -d creates 0700; the runtime user must be able to enter the release. chmod 0755 "${rel}" ln -sfn "${DB}" "${rel}/hito.sqlite" subject="$(git --git-dir="${BARE}" log -1 --format=%s "${sha}")" version="$(git --git-dir="${BARE}" log -1 --format=%cd \ --date=format:%Y%m%d "${sha}")" jq -n \ --arg commit "${sha}" \ --arg subject "${subject}" \ --arg branch "master" \ --arg deployed_at "${ts}" \ --argjson build_seconds "${build_secs}" \ --arg release "${rel}" \ '{commit: $commit, subject: $subject, branch: $branch, deployed_at: $deployed_at, build_seconds: $build_seconds, release: $release}' \ >"${rel}/deploy-info.json" printf '%s\t%s\t%ss\t%s\t%s\n' \ "${ts}" "${sha:0:12}" "${build_secs}" "master" "${subject}" \ >>"${SHARED}/deploy-history.tsv" printf 'HITO_VERSION=%s\n' "${version}" >"${SHARED}/deploy.env" chown -R "${SVC_USER}:${SVC_GROUP}" "${rel}" chown "${SVC_USER}:${SVC_GROUP}" "${SHARED}/deploy-history.tsv" "${SHARED}/deploy.env" ln -sfn "${rel}" "${SRV}/current.tmp" mv -T "${SRV}/current.tmp" "${SRV}/current" chown -h "${SVC_USER}:${SVC_GROUP}" "${SRV}/current" echo " current -> ${rel} (built in ${build_secs}s)" systemctl restart hito if health_ok; then echo " healthy at ${HEALTH_URL}" return 0 fi if [[ -n "${prev}" && -d "${prev}" ]]; then echo " health check failed; rolling back to ${prev}" ln -sfn "${prev}" "${SRV}/current.tmp" mv -T "${SRV}/current.tmp" "${SRV}/current" systemctl restart hito || true echo " rolled back; recent journal:" >&2 journalctl -u hito -n 15 --no-pager >&2 exit 1 fi echo " health check failed and no previous release; journal:" >&2 journalctl -u hito -n 15 --no-pager >&2 exit 1 } cmd_install() { echo "== ${SVC_USER}-owned layout ==" install -d -o "${SVC_USER}" -g "${SVC_GROUP}" -m 0755 \ "${SRV}" "${SHARED}" "${RELEASES}" echo "== runtime user and data ==" ensure_run_user ensure_data echo "== git-owned opam root ==" mkdir -p "${OPAMROOT_DIR}" chown "${SVC_USER}:${SVC_GROUP}" "${OPAMROOT_DIR}" echo "== shared opam switch =="; ensure_switch echo "== receive hooks =="; install_hooks echo "== scoped sudoers =="; install_sudoers echo "== systemd unit =="; install_unit echo echo "Done. Run 'sudo bash setup.sh deploy' to build the first release," echo "or push master to trigger the hook." } cmd_deploy() { if [[ ! -d "${SWITCH}" ]]; then echo "FATAL: no shared switch; run 'install' first" >&2 exit 1 fi echo "== systemd unit =="; install_unit echo "== deploy master =="; build_and_flip } cmd_reset() { if [[ ! -f "${DB}" ]]; then echo "FATAL: no database at ${DB}; aborting before wipe" >&2 exit 1 fi local stash echo "== stop service =="; systemctl stop hito || true echo "== stash the data directory ==" stash="$(mktemp -d /root/hito-reset-stash.XXXXXX)" cp -a "${DATA}/." "${stash}/" # safety copy, never removed by us echo " DB backup at ${stash}/hito.sqlite" echo "== wipe /srv/hito contents except data/ ==" find "${SRV}" -mindepth 1 -maxdepth 1 ! -path "${DATA}" -exec rm -rf {} + echo "== rebuild skeleton, ${SVC_USER}-owned ==" mkdir -p "${SHARED}" "${RELEASES}" chown "${SVC_USER}:${SVC_GROUP}" "${SRV}" "${SHARED}" "${RELEASES}" mkdir -p "${OPAMROOT_DIR}" chown "${SVC_USER}:${SVC_GROUP}" "${OPAMROOT_DIR}" ensure_switch echo "== deploy master =="; build_and_flip echo echo "Done. Safety DB backup remains at ${stash}/hito.sqlite (remove when confident)." } main() { require_root # Run from a directory that no subcommand deletes. reset wipes /srv/hito, so a # caller sitting in /srv/hito/... would otherwise leave child processes with a # nonexistent working directory. cd / case "${1:-}" in install) cmd_install ;; deploy) cmd_deploy ;; reset) cmd_reset ;; *) echo "usage: sudo bash setup.sh {install|deploy|reset}" >&2 exit 2 ;; esac } main "$@"