Files
shop-server/scripts/deploy-ssh.sh
T
2026-05-13 22:07:46 +05:00

262 lines
10 KiB
Bash

#!/usr/bin/env bash
# Деплой по SSH: отдельно фронт (сборка + rsync dist), бэк (rsync + npm ci + prisma migrate).
#
# Требования: bash, ssh; на Linux/macOS используется rsync, на Git Bash/WIN — tar|ssh (обход проблем cwRsync с путами).
#
# Конфиг: переменные окружения или файл scripts/deploy.env (скопируйте из scripts/deploy.env.example).
#
# Примеры:
# ./scripts/deploy-ssh.sh --backend-only
# ./scripts/deploy-ssh.sh --frontend-only
# ./scripts/deploy-ssh.sh --all
# DEPLOY_HOST=10.0.0.5 ./scripts/deploy-ssh.sh -b
set -euo pipefail
# Git Bash вызывает Win32 ssh.exe: аргументы вроде /opt/... иначе подменяются → «mkdir: missing operand».
case "$(uname -s 2>/dev/null)" in
MINGW* | MSYS*) export MSYS2_ARG_CONV_EXCL="*" ;;
esac
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Загрузка deploy.env без перезаписи уже экспортированных переменных
if [[ -f "$SCRIPT_DIR/deploy.env" ]]; then
set -a
# shellcheck source=/dev/null
source "$SCRIPT_DIR/deploy.env"
set +a
fi
DEPLOY_HOST="${DEPLOY_HOST:-}"
DEPLOY_USER="${DEPLOY_USER:-root}"
DEPLOY_PATH="${DEPLOY_PATH:-/opt/craftshop}"
DEPLOY_FRONTEND_DIST="${DEPLOY_FRONTEND_DIST:-$DEPLOY_PATH/www}"
DEPLOY_SSH_IDENTITY="${DEPLOY_SSH_IDENTITY:-}"
DEPLOY_RESTART_CMD="${DEPLOY_RESTART_CMD:-}" # от root: systemctl restart craftshop-api; от непривилегированного: sudo …
# При SSH от root файлы после rsync оказываются root:root; если API в systemd под другим пользователем (bootstrap: deploy), нужен chown:
DEPLOY_SERVER_OWNER="${DEPLOY_SERVER_OWNER:-deploy}"
DEPLOY_SKIP_CHOWN="${DEPLOY_SKIP_CHOWN:-0}" # 1 — не вызывать chown (например API тоже под root)
RSYNC_OPTS=(-az --delete --human-readable --progress)
SSH_OPTS=()
if [[ -n "${DEPLOY_SSH_IDENTITY}" ]]; then
SSH_OPTS+=(-i "$DEPLOY_SSH_IDENTITY")
fi
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
SSH_BASE=(ssh "${SSH_OPTS[@]}" -o StrictHostKeyChecking=accept-new "$REMOTE")
should_use_tar_transport() {
case "$(uname -s 2>/dev/null)" in
MINGW*|MSYS*|CYGWIN_NT*) return 0 ;;
*) return 1 ;;
esac
}
usage() {
cat <<USAGE
Использование: $(basename "$0") [опции]
--frontend-only | -f Деплой только фронта (локальная сборка + rsync dist)
--backend-only | -b Деплой только бэкенда (rsync server + npm ci + prisma)
--all | -a Оба компонента (по умолчанию)
--dry-run Только показать команды rsync (без записи на сервер)
--skip-build Не вызывать npm run build (использовать текущий client/dist)
Окружение (или scripts/deploy.env):
DEPLOY_HOST хост SSH (обязательно)
DEPLOY_USER пользователь SSH (по умолчанию: root)
DEPLOY_PATH корень приложения на сервере (по умолчанию: /opt/craftshop)
DEPLOY_FRONTEND_DIST каталог под статику SPA (по умолчанию: \$DEPLOY_PATH/www)
DEPLOY_SSH_IDENTITY путь к приватному ключу (-i ssh)
DEPLOY_RESTART_CMD команда после бэкенд-деплоя (опционально; под root без sudo)
DEPLOY_SERVER_OWNER при DEPLOY_USER=root: владелец \$DEPLOY_PATH/server после деплоя (по умолчанию: deploy)
DEPLOY_SKIP_CHOWN 1 — не менять владельца каталога server (если процесс под root и т.п.)
USAGE
}
TARGET="all"
DRY_RUN=""
SKIP_BUILD=""
while [[ $# -gt 0 ]]; do
case "$1" in
--frontend-only | -f) TARGET="frontend" ;;
--backend-only | -b) TARGET="backend" ;;
--all | -a) TARGET="all" ;;
--dry-run) DRY_RUN=1 ;;
--skip-build) SKIP_BUILD=1 ;;
-h | --help) usage; exit 0 ;;
*) echo "Неизвестный аргумент: $1" >&2; usage >&2; exit 1 ;;
esac
shift
done
if [[ -z "$DEPLOY_HOST" ]]; then
echo "Укажите DEPLOY_HOST (или добавьте в scripts/deploy.env)" >&2
exit 1
fi
remote_exec() {
"${SSH_BASE[@]}" "$@"
}
if [[ -n "$DRY_RUN" ]]; then
RSYNC_OPTS+=(--dry-run)
fi
# Строка для rsync -e (одна подкоманда; пути без пробелов в -i надёжнее)
build_rsync_rsh() {
printf '%q ' ssh "${SSH_OPTS[@]}" -o StrictHostKeyChecking=accept-new
}
deploy_backend() {
remote_exec mkdir -p "$DEPLOY_PATH/server"
remote_exec mkdir -p "$DEPLOY_PATH/shared"
if should_use_tar_transport; then
echo ">>> Бэкенд (server): tar|ssh → $REMOTE:$DEPLOY_PATH/server/"
if [[ -n "$DRY_RUN" ]]; then
echo "(dry-run) без передачи tar"
else
(
cd "$ROOT/server" || exit 1
tar -czf - \
--exclude=node_modules \
--exclude=uploads \
--exclude=.git \
--exclude='*.db' \
--exclude=.env \
--exclude=.dev_env \
.
) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/server && tar xzf - -C ${DEPLOY_PATH}/server"
echo ">>> Бэкенд (shared): tar|ssh → $REMOTE:$DEPLOY_PATH/shared/"
(
cd "$ROOT/shared" || exit 1
tar -czf - \
--exclude=.git \
.
) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_PATH}/shared && tar xzf - -C ${DEPLOY_PATH}/shared"
fi
else
echo ">>> Бэкенд (server): rsync → $REMOTE:$DEPLOY_PATH/server/"
local rsh
rsh="$(build_rsync_rsh)"
rsync "${RSYNC_OPTS[@]}" \
-e "$rsh" \
--exclude node_modules \
--exclude uploads \
--exclude .git \
--exclude '*.db' \
--exclude .env \
--exclude .dev_env \
"${ROOT}/server/" "${REMOTE}:${DEPLOY_PATH}/server/"
echo ">>> Бэкенд (shared): rsync → $REMOTE:$DEPLOY_PATH/shared/"
rsync "${RSYNC_OPTS[@]}" \
-e "$rsh" \
--exclude .git \
"${ROOT}/shared/" "${REMOTE}:${DEPLOY_PATH}/shared/"
fi
if [[ -n "$DRY_RUN" ]]; then
echo "(dry-run) пропуск удалённых команд npm/prisma"
return 0
fi
echo ">>> Бэкенд: npm ci, prisma generate, migrate deploy на сервере"
remote_exec bash -lc "set -e
cd \"$DEPLOY_PATH/server\"
npm ci
npx prisma generate
npx prisma migrate deploy
"
if [[ "${DEPLOY_USER}" == "root" && "${DEPLOY_SKIP_CHOWN}" != "1" ]]; then
echo ">>> Права на серверный каталог: chown ${DEPLOY_SERVER_OWNER} (деплой от root)"
remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/server"
remote_exec chown -R "${DEPLOY_SERVER_OWNER}:${DEPLOY_SERVER_OWNER}" "$DEPLOY_PATH/shared"
fi
if [[ -n "${DEPLOY_RESTART_CMD}" ]]; then
echo ">>> Рестарт: $DEPLOY_RESTART_CMD"
remote_exec bash -lc "$DEPLOY_RESTART_CMD"
elif [[ -z "$DRY_RUN" ]]; then
echo "" >&2
echo "ВНИМАНИЕ: код сервера обновлён, но процесс Node не перезапущен." >&2
echo " Без рестарта новые маршруты (и правки API) не появятся." >&2
echo " Задайте в deploy.env: DEPLOY_RESTART_CMD='systemctl restart <ваш-сервис-api>'" >&2
echo "" >&2
fi
}
deploy_frontend() {
if [[ -z "$SKIP_BUILD" ]]; then
echo ">>> Фронт: npm ci и npm run build (локально)"
# Windows: ESLint/typescript-eslint тянут @unrs/*.node — npm ci часто получает EPERM unlink, если файл держит Node/IDE или остался мусор .resolver-binding-* после сбоя.
if should_use_tar_transport; then
echo ">>> (Windows/Git Bash) перед npm ci: снимаем блокировки нативных .node (@unrs, @rolldown, .resolver-binding-*)"
echo ">>> Подсказка: остановите «npm run dev» / dev-серверы и IDE, если EPERM останется."
rm -rf "$ROOT/client/node_modules/@unrs" "$ROOT/client/node_modules/@rolldown" 2>/dev/null || true
(
cd "$ROOT/client/node_modules" 2>/dev/null || exit 0
shopt -s nullglob
for x in ./.resolver-binding-*; do
[[ -d "$x" ]] && rm -rf "$x"
done
)
fi
(cd "$ROOT/client" && npm ci && npm run build) || {
echo "" >&2
echo "Сборка фронта не удалась. На Windows часто EPERM на .node — закройте процессы Node (dev-сервер), повторите." >&2
echo "Или соберите фронт вручную (cd client && npm run build), затем: $0 --frontend-only --skip-build" >&2
exit 1
}
else
echo ">>> Фронт: сборка пропущена (--skip-build)"
if [[ ! -d "$ROOT/client/dist" ]]; then
echo "Нет $ROOT/client/dist — выполните сборку без --skip-build" >&2
exit 1
fi
fi
remote_exec mkdir -p "$DEPLOY_FRONTEND_DIST"
if should_use_tar_transport; then
echo ">>> Фронт: tar|ssh dist → $REMOTE:$DEPLOY_FRONTEND_DIST/"
if [[ -n "$DRY_RUN" ]]; then
echo "(dry-run) без передачи tar (www)"
else
remote_exec "mkdir -p ${DEPLOY_FRONTEND_DIST} && find ${DEPLOY_FRONTEND_DIST} -mindepth 1 -delete 2>/dev/null || true"
(
cd "$ROOT/client/dist" || exit 1
tar -czf - .
) | "${SSH_BASE[@]}" "mkdir -p ${DEPLOY_FRONTEND_DIST} && tar xzf - -C ${DEPLOY_FRONTEND_DIST}"
fi
else
echo ">>> Фронт: rsync dist → $REMOTE:$DEPLOY_FRONTEND_DIST/"
local rsh
rsh="$(build_rsync_rsh)"
rsync "${RSYNC_OPTS[@]}" \
-e "$rsh" \
"${ROOT}/client/dist/" "${REMOTE}:${DEPLOY_FRONTEND_DIST}/"
fi
echo ">>> Фронт готов (проверьте nginx/root на путь $DEPLOY_FRONTEND_DIST)"
}
case "$TARGET" in
backend) deploy_backend ;;
frontend) deploy_frontend ;;
all)
deploy_backend
deploy_frontend
;;
*) echo "internal: bad TARGET=$TARGET" >&2; exit 1 ;;
esac
echo "Готово."