#!/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/.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 [git-ref] deploy from the bare repo (default ref: main) exo-deploy --zip 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="" cleanup() { if [[ -n $STAGE ]]; then rm -rf "$STAGE"; fi; } trap cleanup EXIT # Shallowest / 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" 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 ]] || { rm -rf "$REL"; 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." # 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