Files
exodevices/deploy/bin/exo-deploy
T
Yuriy PanovandClaude Opus 5 da93bbc9f3 Remove the release directory a failed deploy leaves behind
A deploy that died after mkdir -- a broken build, a missing lock file, a
health check that never went green -- left its half-finished release in
releases/. That is not just disk: it is the newest directory there, so the
KEEP=3 rotation of the next successful deploy counted it among the three to
keep and dropped a working release instead, thinning out exactly the rollback
targets the runbook tells you to use.

The release directory is now armed for cleanup the moment it is created and
disarmed once the release is live and healthy, with the removal done by the
same EXIT trap that drops the unpacked archive -- so it covers every way out:
fatal, a command failing under set -e, an aborted build. The one case left
alone is a release the swap did reach and that `current` still points at, i.e.
a first-ever deploy with nothing to roll back to: removing that would leave a
dangling symlink behind instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:33:54 +03:00

292 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# Build landings into fresh release directories and swap them in.
# Install as /usr/local/bin/exo-deploy (mode 755); run as the `exo` user.
#
# sudo -u exo exo-deploy medcenterphysio # from git, origin/main
# sudo -u exo exo-deploy medcenterphysio my-branch
# sudo -u exo exo-deploy --zip /tmp/exo.zip medcenterphysio
# sudo -u exo exo-deploy --zip /tmp/exo.zip all # all four, in order
#
# The zip must contain the landing source directories (fitnes/, hotel/, ...),
# not a prebuilt dist/: the build always runs here, so VITE_SITE_URL comes from
# /etc/exo/<app>.build.env and never from whoever packed the archive.
set -Eeuo pipefail
APPS_FALLBACK=(fitnes hotel medcenterphysio medcenterstart)
EXO_ROOT="${EXO_ROOT:-/srv/exo}"
EXO_ETC="${EXO_ETC:-/etc/exo}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
KEEP=3
usage() {
cat <<'USAGE'
usage:
exo-deploy <app|all> [git-ref] deploy from the bare repo (default ref: main)
exo-deploy --zip <archive.zip> <app|all> deploy from a zip of the landing sources
apps: fitnes | hotel | medcenterphysio | medcenterstart | all
USAGE
}
fatal() { echo "FATAL: $*" >&2; exit 1; }
sha256() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
else shasum -a 256 "$1" | cut -d' ' -f1; fi
}
# apps.conf is the source of truth when the script is run from the repo; the
# installed copy in /usr/local/bin has no repo next to it, hence the fallback.
known_apps() {
if [[ -r $HERE/../apps.conf ]]; then
awk -F'|' '/^[a-z]/ { print $1 }' "$HERE/../apps.conf"
else
printf '%s\n' "${APPS_FALLBACK[@]}"
fi
}
# ---------------------------------------------------------------- arguments
ZIP=""
POS=()
while (($#)); do
case $1 in
--zip) ZIP="${2:-}"; [[ -n $ZIP ]] || fatal "--zip needs a path"; shift 2 ;;
--zip=*) ZIP="${1#--zip=}"; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; while (($#)); do POS+=("$1"); shift; done ;;
-*) usage >&2; fatal "unknown option $1" ;;
*) POS+=("$1"); shift ;;
esac
done
APP="${POS[0]:-}"
REF="${POS[1]:-}"
[[ -n $APP ]] || { usage >&2; exit 1; }
[[ -z $ZIP || -z $REF ]] || fatal "--zip takes no git ref (got '$REF')"
[[ -n $ZIP ]] || REF="${REF:-main}"
# Resolve the archive against the caller's directory, then leave it: the usual
# invocation is `sudo -u exo` from root's shell, and `exo` cannot read /root —
# GNU find complains it cannot restore that cwd. Everything below is absolute.
if [[ -n $ZIP && $ZIP != /* ]]; then ZIP="$PWD/$ZIP"; fi
cd /
ALL_APPS=()
while IFS= read -r a; do
if [[ -n $a ]]; then ALL_APPS+=("$a"); fi
done < <(known_apps)
[[ ${#ALL_APPS[@]} -gt 0 ]] || fatal "no apps found — check apps.conf"
TARGETS=()
if [[ $APP == all ]]; then
TARGETS=("${ALL_APPS[@]}")
else
for a in "${ALL_APPS[@]}"; do
if [[ $a == "$APP" ]]; then TARGETS=("$APP"); fi
done
[[ ${#TARGETS[@]} -gt 0 ]] || fatal "unknown app '$APP' — known: ${ALL_APPS[*]} | all"
fi
# ------------------------------------------------------- archive, if any
STAGE=""
PENDING_REL="" # release being built; cleared once it is live and healthy
PENDING_LINK="" # its app's `current` symlink
# A release that never went live is not just wasted disk: it is the newest
# directory in releases/, so the KEEP rotation below would keep it and drop a
# working release instead — the very one the rollback in the runbook needs.
# Anything the swap did reach is left alone, dangling symlinks included.
cleanup() {
if [[ -n $STAGE ]]; then rm -rf "$STAGE"; fi
if [[ -n $PENDING_REL && -d $PENDING_REL ]] &&
[[ "$(readlink -f "$PENDING_LINK" 2>/dev/null || true)" != "$PENDING_REL" ]]; then
echo "==> removing incomplete release $PENDING_REL"
rm -rf "$PENDING_REL"
fi
}
trap cleanup EXIT
# Shallowest <app>/ directory in the archive that holds a package.json, so both
# `fitnes/...` and `bundle/fitnes/...` (what Finder produces) work.
app_src() {
local app="$1" d
while IFS= read -r d; do
if [[ -f $d/package.json ]]; then
printf '%s\n' "$d"
return 0
fi
done < <(find "$STAGE" -maxdepth 4 -type d -name "$app" \
| awk '{ print gsub(/\//,"/"), $0 }' | sort -n | cut -d' ' -f2-)
return 1
}
if [[ -n $ZIP ]]; then
[[ -r $ZIP ]] || fatal "cannot read $ZIP"
command -v unzip >/dev/null 2>&1 || fatal "unzip is not installed (apt install -y unzip)"
STAGE="$(mktemp -d)"
echo "==> unpacking $ZIP"
# node_modules/dist are rebuilt here; __MACOSX/.DS_Store are Finder litter.
# unzip exits 1 and chatters on stderr for every exclude pattern the archive
# happens not to contain, which is normal here — filter it, keep the rest.
UNZIP_ERR="$(unzip -q -o "$ZIP" -d "$STAGE" \
-x 'node_modules/*' '*/node_modules/*' 'dist/*' '*/dist/*' \
'__MACOSX/*' '*/__MACOSX/*' '.DS_Store' '*/.DS_Store' 2>&1)" || {
rc=$?
(( rc <= 1 )) || { echo "$UNZIP_ERR" >&2; fatal "unzip failed (exit $rc)"; }
}
echo "$UNZIP_ERR" | grep -v 'excluded filename not matched' | grep . >&2 || true
# Validate every target up front, and report all of them at once: better to
# hear about a missing lock file now than after two landings are already live.
PROBLEMS=()
for app in "${TARGETS[@]}"; do
if ! src="$(app_src "$app")"; then
PROBLEMS+=("no $app/ directory with a package.json inside")
continue
fi
if [[ ! -f $src/package-lock.json ]]; then
PROBLEMS+=("$app/package-lock.json is missing — npm ci needs it")
continue
fi
echo " found $app -> ${src#$STAGE/}"
done
if [[ ${#PROBLEMS[@]} -gt 0 ]]; then
for p in "${PROBLEMS[@]}"; do echo "FATAL: $(basename "$ZIP"): $p" >&2; done
exit 1
fi
ZIP_SHA="$(sha256 "$ZIP")"
SOURCE_DESC="zip $(basename "$ZIP") sha256=$ZIP_SHA"
else
SOURCE_DESC="" # filled per app, it carries the resolved commit
fi
# ------------------------------------------------------------- deploy one
deploy_app() {
local APP="$1"
local BASE="$EXO_ROOT/$APP"
local ENVFILE="$EXO_ETC/$APP.env"
local BUILDENV="$EXO_ETC/$APP.build.env"
local REL="$BASE/releases/$(date -u +%Y%m%d-%H%M%S)"
local PORT PREV HEALTH CURRENT src desc
for f in "$ENVFILE" "$BUILDENV"; do
[[ -r $f ]] || fatal "cannot read $f"
done
PORT="$(sed -n 's/^PORT=//p' "$ENVFILE")"
[[ -n $PORT ]] || fatal "PORT not set in $ENVFILE"
mkdir -p "$REL"
PENDING_REL="$REL"
PENDING_LINK="$BASE/current"
if [[ -n $ZIP ]]; then
src="$(app_src "$APP")" || fatal "$ZIP: no $APP/ directory with a package.json inside"
echo "==> copying $APP/ out of $(basename "$ZIP") into $REL"
cp -a "$src/." "$REL/"
desc="$SOURCE_DESC"
# Whatever local config the packer had in there must not survive: the build
# env below is the only .env a release is allowed to carry.
rm -f "$REL/.env" "$REL/.env.local"
else
[[ -d $BASE/repo ]] || fatal "no bare repo at $BASE/repo"
echo "==> fetching $REF"
git -C "$BASE/repo" fetch --prune origin '+refs/heads/*:refs/heads/*'
git -C "$BASE/repo" rev-parse --verify "$REF^{commit}" >/dev/null
echo "==> extracting $APP/ into $REL"
git -C "$BASE/repo" archive "$REF" "$APP" | tar -x -C "$REL" --strip-components=1
[[ -f $REL/package.json ]] || fatal "$APP/ not found at $REF"
desc="git $REF $(git -C "$BASE/repo" rev-parse --short "$REF")"
fi
# 2.6-3.9 MB of base64-embedded reference HTML that is never served.
rm -rf "$REL/legacy"
# So a live release can say where it came from long after the deploy scrolled
# off the screen.
printf '%s\n%s\n' "$desc" "deployed $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$REL/.deploy-source"
# Public build-time vars only. The amoCRM token stays in $ENVFILE, which
# systemd injects at runtime; dotenv does not override real env vars, so the
# two never collide.
cp "$BUILDENV" "$REL/.env"
cd "$REL"
# devDependencies are REQUIRED here: vite, typescript and tailwindcss all live
# there and `npm run build` needs them. --omit=dev breaks the build.
echo "==> npm ci"
npm ci --no-audit --no-fund
echo "==> npm run build"
npm run build
# Fail loudly rather than shipping broken SEO tags or an empty bundle.
[[ -s dist/client/index.html ]] || fatal "dist/client/index.html missing or empty"
if grep -q '%VITE_SITE_URL%' dist/client/index.html; then
fatal "VITE_SITE_URL was not substituted — check $BUILDENV"
fi
# Runtime needs only express/zod/dotenv/tsx: ~130 MB -> ~40 MB per release.
echo "==> pruning devDependencies"
npm prune --omit=dev
# Precompress for nginx gzip_static. .webp/.woff2 omitted on purpose.
find dist/client -type f \
\( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' -o -name '*.xml' -o -name '*.txt' \) \
-exec gzip -9 -k -f {} +
echo "==> swapping $BASE/current -> $REL"
PREV="$(readlink -f "$BASE/current" 2>/dev/null || true)"
ln -sfn "$REL" "$BASE/current.tmp"
mv -Tf "$BASE/current.tmp" "$BASE/current" # single rename(2): atomic
sudo systemctl restart "exo@$APP"
echo "==> waiting for health on 127.0.0.1:$PORT"
for _ in $(seq 1 20); do
if curl -fsS --max-time 2 "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then break; fi
sleep 1
done
HEALTH="$(curl -fsS --max-time 5 "http://127.0.0.1:$PORT/api/health")" || {
echo "FATAL: health check failed. Rolling back." >&2
if [[ -n $PREV ]]; then
ln -sfn "$PREV" "$BASE/current.tmp"
mv -Tf "$BASE/current.tmp" "$BASE/current"
sudo systemctl restart "exo@$APP"
fi
exit 1
}
echo " $HEALTH"
grep -q '"ok":true' <<<"$HEALTH" || fatal "health not ok"
grep -q '"amo":true' <<<"$HEALTH" || echo " WARNING: amo=false — /api/leads/* will return 503. Check AMO_* in $ENVFILE."
# Live and healthy: from here on it is a release like any other.
PENDING_REL=""
PENDING_LINK=""
# Prune old releases, never the live one.
CURRENT="$(readlink -f "$BASE/current")"
ls -1dt "$BASE"/releases/*/ 2>/dev/null | tail -n "+$((KEEP+1))" | while read -r old; do
[[ "$(readlink -f "$old")" == "$CURRENT" ]] && continue
rm -rf "$old"
done
echo "==> deployed $APP [$desc] -> $REL"
}
# ------------------------------------------------------------------ run
n=0
for app in "${TARGETS[@]}"; do
n=$((n + 1))
if [[ ${#TARGETS[@]} -gt 1 ]]; then echo "### [$n/${#TARGETS[@]}] $app"; fi
deploy_app "$app"
done