diff --git a/deploy/README.md b/deploy/README.md index b5e83b8..576837a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -52,7 +52,7 @@ No database, no Redis, no Docker, no PM2. See §14 of the plan for why not Docke ```bash apt update && apt full-upgrade -y -apt install -y git curl nginx ufw fail2ban unattended-upgrades gzip +apt install -y git curl nginx ufw fail2ban unattended-upgrades gzip unzip timedatectl set-timezone Europe/Moscow dpkg-reconfigure --priority=low unattended-upgrades @@ -188,14 +188,75 @@ Order matters: nginx needs the port-80 vhost live before certbot can validate. ```bash install -m 755 bin/exo-deploy /usr/local/bin/ -sudo -u exo exo-deploy medcenterphysio main +sudo -u exo exo-deploy medcenterphysio main # one app +sudo -u exo exo-deploy all # all four, in apps.conf order +``` + +## Deploying from a zip archive + +`exo-deploy` takes its code from one of two places: the bare repo (above) or a zip +archive sitting on the server. The archive path exists for code that is not in git on +this box — a handover build, a contractor's snapshot, a hotfix from a laptop. + +```bash +exo-deploy [git-ref] # from the bare repo, default ref main +exo-deploy --zip # from an archive +``` + +**The archive holds sources, not a build.** Everything after the source lands in the +release directory is identical in both modes — `npm ci`, `npm run build`, the +`%VITE_SITE_URL%` check, `npm prune`, gzip, the atomic `current` swap, the health check +with automatic rollback. That is the point: `VITE_SITE_URL` comes from +`/etc/exo/.build.env` on this server and never from whoever packed the archive. + +Expected layout — the landing directories as they sit in the repo, either at the root of +the archive or under one wrapping directory (what Finder's "Compress" produces): + +``` +fitnes/ hotel/ medcenterphysio/ medcenterstart/ # or bundle/fitnes/ ... + package.json, package-lock.json, index.html, + src/, server/, shared/, scripts/, public/, tsconfig*.json, vite.config.ts +``` + +`package-lock.json` is required — `npm ci` refuses to run without it, and `exo-deploy` +says so up front for every app in the archive rather than failing halfway through. +`node_modules/`, `dist/`, `__MACOSX/` and `.DS_Store` are skipped during unpacking, and +any `.env` / `.env.local` that travelled inside the archive is deleted before the build. + +Pack it on the dev machine, from the repo root: + +```bash +zip -r exo-$(date +%Y%m%d).zip fitnes hotel medcenterphysio medcenterstart \ + -x '*/node_modules/*' '*/dist/*' '*/.env' '*/.env.local' '*/.DS_Store' '*/legacy/*' +``` + +Then ship and deploy. `/tmp` works; the file only has to be readable by `exo`: + +```bash +scp exo-20260101.zip :/tmp/ +sudo -u exo exo-deploy --zip /tmp/exo-20260101.zip all +``` + +`all` runs the four in `apps.conf` order and **stops at the first failure** — landings +already swapped in stay on their new release, the one that failed rolls itself back. +Rerun for the rest once the cause is fixed. + +Each release records where it came from, so a live one can be traced long after the +deploy output has scrolled away: + +```bash +cat /srv/exo//current/.deploy-source +# zip exo-20260101.zip sha256=9f86d0... +# deployed 2026-01-01T09:12:44Z ``` ## Runbook | Task | Command | |---|---| -| Deploy | `sudo -u exo exo-deploy [ref]` | +| Deploy | `sudo -u exo exo-deploy [ref]` | +| Deploy from a zip | `sudo -u exo exo-deploy --zip ` | +| What is live | `cat /srv/exo//current/.deploy-source` | | Rollback | `ln -sfn /srv/exo//releases/ /srv/exo//current && sudo systemctl restart exo@` | | Tail logs | `journalctl -u exo@ -f` | | All leads, last hour | `journalctl -u 'exo@*' --since '1 hour ago' \| grep '\[lead\]'` | @@ -239,7 +300,8 @@ Also enable reg.ru VPS snapshots — a full-image restore beats rebuilding under limiter, single-process by design. Horizontal scaling needs a shared store first. Not a concern at landing-page traffic; nginx `limit_req` is the second layer. - **No CI.** Deployment is a manual `exo-deploy`. A GitHub Actions job that SSHes and - runs it is a natural follow-up once the flow is proven. + runs it is a natural follow-up once the flow is proven. Until then `--zip` is the way + to deploy code that never reached this server's git remote. - **One Metrika counter for four domains.** All four landings carry the same Yandex.Metrika counter (`112352796`) in `index.html`, so reports mix the domains and every domain has to be listed in the counter's settings, or its hits get filtered. diff --git a/deploy/bin/exo-deploy b/deploy/bin/exo-deploy index 0f375a0..0cb4a33 100755 --- a/deploy/bin/exo-deploy +++ b/deploy/bin/exo-deploy @@ -1,95 +1,271 @@ #!/usr/bin/env bash -# Build one landing from git into a fresh release directory and swap it in. +# 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 # deploys origin/main +# 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 -APP="${1:?usage: exo-deploy [git-ref]}" -REF="${2:-main}" -BASE="/srv/exo/$APP" -ENVFILE="/etc/exo/$APP.env" -BUILDENV="/etc/exo/$APP.build.env" +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 -REL="$BASE/releases/$(date -u +%Y%m%d-%H%M%S)" -for f in "$ENVFILE" "$BUILDENV"; do - [[ -r $f ]] || { echo "FATAL: cannot read $f"; exit 1; } +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 -[[ -d $BASE/repo ]] || { echo "FATAL: no bare repo at $BASE/repo"; exit 1; } -PORT="$(sed -n 's/^PORT=//p' "$ENVFILE")" -[[ -n $PORT ]] || { echo "FATAL: PORT not set in $ENVFILE"; exit 1; } +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}" -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 +# 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 / -echo "==> extracting $APP/ into $REL" -mkdir -p "$REL" -git -C "$BASE/repo" archive "$REF" "$APP" | tar -x -C "$REL" --strip-components=1 -[[ -f $REL/package.json ]] || { echo "FATAL: $APP/ not found at $REF"; rm -rf "$REL"; exit 1; } +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" -# 2.6-3.9 MB of base64-embedded reference HTML that is never served. -rm -rf "$REL/legacy" - -# 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 ]] || { echo "FATAL: dist/client/index.html missing or empty"; exit 1; } -if grep -q '%VITE_SITE_URL%' dist/client/index.html; then - echo "FATAL: VITE_SITE_URL was not substituted — check $BUILDENV"; exit 1 +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 -# Runtime needs only express/zod/dotenv/tsx: ~130 MB -> ~40 MB per release. -echo "==> pruning devDependencies" -npm prune --omit=dev +# ------------------------------------------------------- archive, if any -# 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 {} + +STAGE="" +cleanup() { if [[ -n $STAGE ]]; then rm -rf "$STAGE"; fi; } +trap cleanup EXIT -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." - [[ -n $PREV ]] && { ln -sfn "$PREV" "$BASE/current.tmp"; mv -Tf "$BASE/current.tmp" "$BASE/current"; sudo systemctl restart "exo@$APP"; } - exit 1 +# 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 } -echo " $HEALTH" -grep -q '"ok":true' <<<"$HEALTH" || { echo "FATAL: health not ok"; exit 1; } -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" +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 - -echo "==> deployed $APP @ $REF ($(git -C "$BASE/repo" rev-parse --short "$REF")) -> $REL"