#!/usr/bin/env bash # Deploy a locally built bokf image to BOKF_HOST over SSH. See docs/DEPLOY.md. # # scripts/deploy.sh [--dev] [tag] # # Normal mode builds an image (cross-compiled on this machine when the target # architecture differs) and recreates the container. --dev skips the image: # it copies the fresh binaries into the running container and reloads the # daemon with SIGHUP, which re-execs them in place. # # BOKF_HOST (user@host) and BOKF_REMOTE_DIR are read from the environment or # from .env in the repository root. The host needs only Docker and Compose. set -euo pipefail cd "$(dirname "$0")/.." env_val() { sed -n "s/^$1=//p" .env 2>/dev/null | tail -n 1 } if [ -f .env ]; then BOKF_HOST="${BOKF_HOST:-$(env_val BOKF_HOST)}" BOKF_REMOTE_DIR="${BOKF_REMOTE_DIR:-$(env_val BOKF_REMOTE_DIR)}" BOKF_BUILD="${BOKF_BUILD:-$(env_val BOKF_BUILD)}" fi : "${BOKF_HOST:?set BOKF_HOST (user@host) in .env or the environment}" BOKF_REMOTE_DIR="${BOKF_REMOTE_DIR:-/srv/bokf}" SSH=(ssh "$BOKF_HOST") DEV=0 if [ "${1:-}" = "--dev" ]; then DEV=1 shift fi TAG="${1:-}" if [ -z "$TAG" ]; then TAG="$(git describe --tags --always --dirty 2>/dev/null || echo dev)" fi case "$TAG" in *[!A-Za-z0-9._-]* | "") echo "deploy: invalid tag '$TAG'" >&2 exit 1 ;; esac remote() { "${SSH[@]}" "$1"; } wait_healthy() { for _ in $(seq 1 30); do cid="$(remote "cd '$BOKF_REMOTE_DIR' && docker compose ps -q bokfd" 2>/dev/null || true)" if [ -n "$cid" ]; then st="$(remote "docker inspect --format '{{.State.Health.Status}}' '$cid'" 2>/dev/null || true)" if [ "$st" = "healthy" ]; then return 0 fi fi sleep 2 done return 1 } cross_build() { echo "deploy: cross-compiling $host_arch binaries on this machine" rm -rf .prebuilt mkdir -p .prebuilt docker build -q -f deploy/Dockerfile.cross -t bokf-cross deploy/ >/dev/null docker run --rm -e "VERSION=$TAG" -v "$PWD":/src:ro \ -v "$PWD/.prebuilt":/out bokf-cross if command -v file >/dev/null 2>&1 && ! file .prebuilt/bokfd | grep -q aarch64; then echo "deploy: cross-build produced no arm64 binary" >&2 rm -rf .prebuilt exit 1 fi } echo "deploy: gate: build + tests" make -j"$(nproc)" make test BOKF_BUILD="${BOKF_BUILD:-auto}" host_arch="$(uname -m)" case "$BOKF_BUILD" in auto) host_arch="$(remote 'uname -m' 2>/dev/null || echo unknown)" if [ "$(uname -m)" = "$host_arch" ]; then BOKF_BUILD=local else echo "deploy: host is $host_arch, cross-building here" BOKF_BUILD=remote fi ;; local | remote) host_arch="$(remote 'uname -m' 2>/dev/null || echo unknown)" ;; *) echo "deploy: BOKF_BUILD must be auto, local or remote" >&2 exit 1 ;; esac if [ "$DEV" = 1 ]; then if ! remote "test -f '$BOKF_REMOTE_DIR/var/db/bokfd.db'"; then echo "deploy: no database on $BOKF_HOST; run a normal deploy first" >&2 exit 1 fi if [ "$BOKF_BUILD" = local ]; then prebin="build" echo "deploy: dev: using the locally built binaries" else cross_build prebin=".prebuilt" fi echo "deploy: dev: copying binaries into the running container" remote "mkdir -p /tmp/bokf-dev" scp -q "$prebin/bokfd" "$prebin/bokfctl" "$prebin/bokftui" \ "$BOKF_HOST:/tmp/bokf-dev/" remote "cd '$BOKF_REMOTE_DIR' cid=\$(docker compose ps -q bokfd) test -n \"\$cid\" || { echo 'deploy: bokfd is not running' >&2; exit 1; } docker cp /tmp/bokf-dev/bokfd \"\$cid:/usr/local/bin/bokfd\" docker cp /tmp/bokf-dev/bokfctl \"\$cid:/usr/local/bin/bokfctl\" docker cp /tmp/bokf-dev/bokftui \"\$cid:/usr/local/bin/bokftui\" docker kill --signal=HUP \"\$cid\"" if [ "$prebin" = ".prebuilt" ]; then rm -rf .prebuilt fi ver="" for _ in $(seq 1 15); do ver="$(remote "cd '$BOKF_REMOTE_DIR' && docker exec \$(docker compose ps -q bokfd) bokfctl meta 2>/dev/null" | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' || true)" [ "$ver" = "$TAG" ] && break sleep 1 done if [ "$ver" = "$TAG" ]; then echo "deploy: dev reloaded, daemon reports $ver" exit 0 fi echo "deploy: dev reload did not come back with $TAG (got '${ver:-nothing}')" >&2 remote "cd '$BOKF_REMOTE_DIR' && docker compose logs --tail=30 bokfd" >&2 || true exit 1 fi if [ "$BOKF_BUILD" = local ]; then echo "deploy: building image bokf:$TAG locally" docker build --build-arg "VERSION=$TAG" -t "bokf:$TAG" . echo "deploy: shipping image to $BOKF_HOST" docker save "bokf:$TAG" | gzip | "${SSH[@]}" 'gunzip | docker load' else cross_build echo "deploy: assembling image bokf:$TAG on $BOKF_HOST" tar -cf - \ --exclude=./.git --exclude=./build --exclude=./var --exclude=./.env \ --exclude='./*.se' --exclude='./*.db' --exclude='./*.db-wal' \ --exclude='./*.db-shm' --exclude=./docs . | "${SSH[@]}" "docker build --build-arg 'VERSION=$TAG' -t 'bokf:$TAG' -" rm -rf .prebuilt fi remote "mkdir -p '$BOKF_REMOTE_DIR'" scp -q compose.yaml "$BOKF_HOST:$BOKF_REMOTE_DIR/compose.yaml" prev_tag="$(remote "grep -E '^BOKF_TAG=' '$BOKF_REMOTE_DIR/.env' 2>/dev/null | cut -d= -f2" || true)" remote "touch '$BOKF_REMOTE_DIR/.env' sed -i '/^BOKF_IMAGE=/d;/^BOKF_TAG=/d' '$BOKF_REMOTE_DIR/.env' printf 'BOKF_IMAGE=bokf\nBOKF_TAG=%s\n' '$TAG' >> '$BOKF_REMOTE_DIR/.env'" if ! remote "test -f '$BOKF_REMOTE_DIR/var/db/bokfd.db'"; then echo "deploy: fresh host: no database yet." echo "deploy: run once on $BOKF_HOST, then deploy again:" echo " cd $BOKF_REMOTE_DIR" echo " docker compose run --rm -e BOKFD_PASSWORD='' bokfd init" echo " docker compose up -d" exit 0 fi echo "deploy: starting containers" remote "cd '$BOKF_REMOTE_DIR' && docker compose up -d --no-build" if wait_healthy; then echo "deploy: bokf:$TAG is healthy on $BOKF_HOST" exit 0 fi echo "deploy: health check failed for bokf:$TAG" >&2 remote "cd '$BOKF_REMOTE_DIR' && docker compose logs --tail=50 bokfd" >&2 || true if [ -n "$prev_tag" ] && [ "$prev_tag" != "$TAG" ]; then echo "deploy: rolling back to bokf:$prev_tag" >&2 remote "sed -i 's/^BOKF_TAG=.*/BOKF_TAG=$prev_tag/' '$BOKF_REMOTE_DIR/.env'" if remote "cd '$BOKF_REMOTE_DIR' && docker compose up -d --no-build" && wait_healthy; then echo "deploy: rollback to bokf:$prev_tag is healthy" >&2 else echo "deploy: rollback FAILED, manual attention needed" >&2 fi fi exit 1