#!/bin/bash # # blut24-landing — release script. Runs ON the target host (192.168.12.81), # invoked over SSH by Jenkins after it has rsynced the checkout into # $APP_ROOT/releases/$RELEASE_ID. # # RELEASE_ID=42-a1b2c3d bash /var/www/blut24.com/releases/42-a1b2c3d/deploy/deploy.sh # # WHY THE BUILD HAPPENS HERE AND NOT ON JENKINS # better-sqlite3 is a native addon compiled against a specific Node ABI, OS and # libc. Building on the Jenkins host (192.168.12.65) and copying the artifact to # .81 works only while both machines stay byte-compatible — and fails silently # later, at runtime, with "invalid ELF header". Building on the target removes # that whole class of problem. It costs nothing in availability, because the # PREVIOUS release keeps serving traffic throughout. # # ZERO DOWNTIME # 1. build the new release completely, while the old one serves # 2. boot it on a throw-away port and health-check it — a build can compile fine # and still crash on start (bad env, missing native binding) # 3. swap the `web` symlink with an atomic rename # 4. `pm2 reload` — cluster mode retires workers one at a time # 5. health-check the live site; on failure, swap back and reload again # set -euo pipefail APP_ROOT="/var/www/blut24.com/web" # code (docroot; proxied, never served as files) SHARED_ROOT="/var/www/blut24.com/private/blut24-landing" # secrets + SQLite, outside the docroot APP_NAME="blut24-landing" NODE_VERSION="v22.22.3" NVM_DIR="/root/.nvm" KEEP_RELEASES=5 PROBE_PORT="${PROBE_PORT:-3999}" HEALTH_PATH="/" # Serialise deploys on the TARGET. Two releases swapping `current` at once would # interleave the symlink rename and the pm2 reload, and could leave the live link # pointing at a half-built release. flock is held for the whole run and released # when the script exits, however it exits. 30 min is far beyond a normal deploy # (~40 s) but short enough that a wedged run cannot block the next one forever. LOCK_FILE="/var/lock/blut24-landing-deploy.lock" exec 9>"$LOCK_FILE" if ! flock -w 1800 9; then echo "another deploy is holding $LOCK_FILE and did not finish within 30 min" >&2 exit 1 fi RELEASE_ID="${RELEASE_ID:?RELEASE_ID must be set}" RELEASE_DIR="$APP_ROOT/releases/$RELEASE_ID" SHARED_DIR="$SHARED_ROOT/shared" LIVE_LINK="$APP_ROOT/current" export NVM_DIR="$NVM_DIR" # shellcheck disable=SC1090 . "$NVM_DIR/nvm.sh" # install-if-missing: bumping NODE_VERSION in git is then all that is needed — # without this a new pin fails the deploy with "N/A: version not yet installed". nvm install "$NODE_VERSION" >/dev/null 2>&1 || nvm use "$NODE_VERSION" >/dev/null command -v "$NVM_DIR/versions/node/$NODE_VERSION/bin/npm" >/dev/null \ || fail "node $NODE_VERSION unavailable on this host" "$NVM_DIR/versions/node/$NODE_VERSION/bin/npm" ls -g pm2 >/dev/null 2>&1 \ || "$NVM_DIR/versions/node/$NODE_VERSION/bin/npm" install -g pm2 >/dev/null 2>&1 NODE_BIN="$NVM_DIR/versions/node/$NODE_VERSION/bin/node" NPM_BIN="$NVM_DIR/versions/node/$NODE_VERSION/bin/npm" PM2_BIN="$NVM_DIR/versions/node/$NODE_VERSION/bin/pm2" log() { echo "[$(date '+%H:%M:%S')] $*"; } fail() { echo "[$(date '+%H:%M:%S')] FAILED: $*" >&2; exit 1; } [ -d "$RELEASE_DIR" ] || fail "release dir $RELEASE_DIR does not exist (did rsync run?)" log "release $RELEASE_ID node $("$NODE_BIN" -v) abi $("$NODE_BIN" -p 'process.versions.modules')" # --------------------------------------------------------------------------- # 1. Wire the release to shared state # --------------------------------------------------------------------------- # The source XML ships in git but the databases do NOT (they are gitignored), so # the release's own data/ dir must be REPLACED by a symlink to shared/data — # otherwise every deploy would serve an empty registrations.db and silently lose # every pending registration. log "linking shared state" mkdir -p "$SHARED_DIR/data" for xml in "$RELEASE_DIR"/data/*.xml "$RELEASE_DIR"/data/*.xsd; do [ -e "$xml" ] || continue target="$SHARED_DIR/data/$(basename "$xml")" if [ ! -f "$target" ] || [ "$xml" -nt "$target" ]; then cp -f "$xml" "$target" log " source data: $(basename "$xml") -> shared" fi done rm -rf "$RELEASE_DIR/data" ln -sfn "$SHARED_DIR/data" "$RELEASE_DIR/data" [ -f "$SHARED_DIR/.env" ] || fail "$SHARED_DIR/.env missing — run deploy/bootstrap-server.sh first" ln -sfn "$SHARED_DIR/.env" "$RELEASE_DIR/.env" # --------------------------------------------------------------------------- # 2. Install + build (old release still serving) # --------------------------------------------------------------------------- cd "$RELEASE_DIR" log "npm ci" # npm ci installs exactly the committed lock file and compiles native addons for # THIS host. --legacy-peer-deps mirrors what the project README documents. "$NPM_BIN" ci --legacy-peer-deps --no-audit --no-fund log "verifying the native addon actually loads on this host" "$NODE_BIN" -e "new (require('better-sqlite3'))(':memory:').close(); console.log(' better-sqlite3 OK')" \ || fail "better-sqlite3 cannot load — native build problem" log "nuxt build" "$NPM_BIN" run build # clinics.db is derived from the shipped XML; rebuild it only when it is missing # or older than the source, so a deploy never needlessly churns it. XML_SRC=$(ls -t "$SHARED_DIR"/data/*TVERZ_Export.xml 2>/dev/null | head -1 || true) if [ -n "$XML_SRC" ]; then if [ ! -f "$SHARED_DIR/data/clinics.db" ] || [ "$XML_SRC" -nt "$SHARED_DIR/data/clinics.db" ]; then log "rebuilding clinics.db from $(basename "$XML_SRC")" "$NPM_BIN" run import:clinics else log "clinics.db is current, skipping import" fi fi # --------------------------------------------------------------------------- # 3. Pre-flight: boot the new build in isolation before it can touch traffic # --------------------------------------------------------------------------- log "pre-flight boot on port $PROBE_PORT" set -a; . "$SHARED_DIR/.env"; set +a PROBE_LOG=$(mktemp) NITRO_PORT="$PROBE_PORT" NITRO_HOST=127.0.0.1 "$NODE_BIN" "$RELEASE_DIR/.output/server/index.mjs" >"$PROBE_LOG" 2>&1 & PROBE_PID=$! probe_ok=0 for _ in $(seq 1 30); do sleep 1 if curl -fsS -m 3 "http://127.0.0.1:$PROBE_PORT$HEALTH_PATH" -o /dev/null 2>/dev/null; then probe_ok=1; break; fi kill -0 "$PROBE_PID" 2>/dev/null || break # process died done kill "$PROBE_PID" 2>/dev/null || true wait "$PROBE_PID" 2>/dev/null || true if [ "$probe_ok" -ne 1 ]; then echo "--- pre-flight output ---"; tail -40 "$PROBE_LOG"; rm -f "$PROBE_LOG" fail "new release does not answer on $HEALTH_PATH — NOT swapping, live site untouched" fi rm -f "$PROBE_LOG" log " pre-flight OK" # --------------------------------------------------------------------------- # 4. Atomic cutover # --------------------------------------------------------------------------- PREVIOUS="" if [ -L "$LIVE_LINK" ]; then PREVIOUS=$(readlink -f "$LIVE_LINK"); fi log "swapping current -> releases/$RELEASE_ID" # ln -sfn is NOT atomic (it unlinks, then creates). Create a temp link and rename # it over the old one — rename(2) is atomic, so no request ever sees a missing current/. ln -sfn "$RELEASE_DIR" "$APP_ROOT/.current.tmp.$$" mv -Tf "$APP_ROOT/.current.tmp.$$" "$LIVE_LINK" cp -f "$RELEASE_DIR/deploy/ecosystem.config.cjs" "$SHARED_ROOT/ecosystem.config.cjs" log "pm2 reload (rolling)" if "$PM2_BIN" describe "$APP_NAME" >/dev/null 2>&1; then "$PM2_BIN" reload "$SHARED_ROOT/ecosystem.config.cjs" --update-env else "$PM2_BIN" start "$SHARED_ROOT/ecosystem.config.cjs" fi # --------------------------------------------------------------------------- # 5. Verify live, roll back if the new release misbehaves under PM2 # --------------------------------------------------------------------------- PORT="${NITRO_PORT:-3002}" live_ok=0 for _ in $(seq 1 30); do sleep 1 if curl -fsS -m 3 "http://127.0.0.1:$PORT$HEALTH_PATH" -o /dev/null 2>/dev/null; then live_ok=1; break; fi done if [ "$live_ok" -ne 1 ]; then log "LIVE HEALTH CHECK FAILED — rolling back" if [ -n "$PREVIOUS" ] && [ -d "$PREVIOUS" ]; then ln -sfn "$PREVIOUS" "$APP_ROOT/.current.tmp.$$" mv -Tf "$APP_ROOT/.current.tmp.$$" "$LIVE_LINK" "$PM2_BIN" reload "$SHARED_ROOT/ecosystem.config.cjs" --update-env || true log "rolled back to $(basename "$PREVIOUS")" else log "no previous release to roll back to" fi "$PM2_BIN" logs "$APP_NAME" --lines 40 --nostream || true fail "release $RELEASE_ID unhealthy" fi "$PM2_BIN" save >/dev/null 2>&1 || true log "live on port $PORT" # --------------------------------------------------------------------------- # 6. Prune # --------------------------------------------------------------------------- CURRENT=$(basename "$(readlink -f "$LIVE_LINK")") cd "$APP_ROOT/releases" ls -1dt */ 2>/dev/null | sed 's#/##' | tail -n +$((KEEP_RELEASES + 1)) | while read -r old; do [ "$old" = "$CURRENT" ] && continue log "pruning old release $old" rm -rf "${APP_ROOT:?}/releases/${old:?}" done log "deploy $RELEASE_ID complete"