Add a zip archive as a second source for exo-deploy

Until now a release could only come from the bare repo on the VPS, so code
that never reached this server's git remote could not be deployed at all.
exo-deploy now also takes a zip of the landing source directories:

  exo-deploy --zip /tmp/exo-20260101.zip medcenterphysio
  exo-deploy --zip /tmp/exo-20260101.zip all

Everything after the source lands in the release directory is unchanged and
shared by both modes — npm ci, the build, the %VITE_SITE_URL% check, prune,
gzip, the atomic swap, the health check with rollback, the KEEP=3 rotation.
The archive therefore carries sources, never a build: VITE_SITE_URL keeps
coming from /etc/exo/<app>.build.env rather than from whoever packed the zip.
Any .env that travelled inside the archive is dropped before the build.

The archive is unpacked once, node_modules/dist/__MACOSX/.DS_Store skipped,
and every target app is validated up front — a missing directory or a missing
package-lock.json is reported for all four at once, before anything on disk
moves. The landing directory is located by its package.json, so both
fitnes/... and bundle/fitnes/... (what Finder's Compress produces) work.

Also new, and shared by both modes: `all` deploys the four in apps.conf order,
stopping at the first failure, and each release records where it came from in
.deploy-source, so a live release can still be traced once the deploy output
is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Yuriy Panov
2026-09-17 22:27:27 +03:00
co-authored by Claude Opus 5
parent fc6a6dde8b
commit 5dd6bbc217
2 changed files with 317 additions and 79 deletions
+66 -4
View File
@@ -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 <app|all> [git-ref] # from the bare repo, default ref main
exo-deploy --zip <archive.zip> <app|all> # 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/<app>.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 <server>:/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/<app>/current/.deploy-source
# zip exo-20260101.zip sha256=9f86d0...
# deployed 2026-01-01T09:12:44Z
```
## Runbook
| Task | Command |
|---|---|
| Deploy | `sudo -u exo exo-deploy <app> [ref]` |
| Deploy | `sudo -u exo exo-deploy <app\|all> [ref]` |
| Deploy from a zip | `sudo -u exo exo-deploy --zip <archive.zip> <app\|all>` |
| What is live | `cat /srv/exo/<app>/current/.deploy-source` |
| Rollback | `ln -sfn /srv/exo/<app>/releases/<older> /srv/exo/<app>/current && sudo systemctl restart exo@<app>` |
| Tail logs | `journalctl -u exo@<app> -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.
+251 -75
View File
@@ -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/<app>.build.env and never from whoever packed the archive.
set -Eeuo pipefail
APP="${1:?usage: exo-deploy <fitnes|hotel|medcenterphysio|medcenterstart> [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 <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
[[ -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 <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
}
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"