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>
292 lines
10 KiB
Bash
Executable File
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
|