236 lines
7.6 KiB
Bash
Executable File
236 lines
7.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# ============================================
|
||
# shanxia2.0 生产一键发版(前端 → 后端)
|
||
#
|
||
# 用法(在仓库任意目录执行均可):
|
||
# ./生产环境部署/deploy-release.sh
|
||
# ./生产环境部署/deploy-release.sh --skip-build # 跳过本地 pnpm build,使用已有 dist
|
||
#
|
||
# 凭据:仓库根目录「生产机器账号密码」
|
||
# 生产路径:
|
||
# 前端 /iflytek/shanxia2.0/web/dist (保留远程 dist/src,禁止删除)
|
||
# 后端 /iflytek/shanxia2.0/backend/app
|
||
# 备份 /iflytek/shanxia2.0/bak/
|
||
# ============================================
|
||
|
||
set -euo pipefail
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||
CRED_FILE="${CRED_FILE:-$REPO_ROOT/生产机器账号密码}"
|
||
|
||
REMOTE_BASE="/iflytek/shanxia2.0"
|
||
REMOTE_WEB="$REMOTE_BASE/web"
|
||
REMOTE_DIST="$REMOTE_WEB/dist"
|
||
REMOTE_BACKEND="$REMOTE_BASE/backend"
|
||
REMOTE_BAK="$REMOTE_BASE/bak"
|
||
|
||
LOCAL_WEB_ADMIN="$REPO_ROOT/web-admin"
|
||
LOCAL_DIST="$LOCAL_WEB_ADMIN/dist"
|
||
LOCAL_APP="$REPO_ROOT/backend/app"
|
||
|
||
SSH_USER="root"
|
||
SSH_OPTS=(-o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30)
|
||
|
||
SKIP_BUILD=0
|
||
for arg in "$@"; do
|
||
case "$arg" in
|
||
--skip-build) SKIP_BUILD=1 ;;
|
||
-h|--help)
|
||
sed -n '2,16p' "$0" | sed 's/^# \?//'
|
||
exit 0
|
||
;;
|
||
*)
|
||
echo "未知参数: $arg(支持 --skip-build)" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
RED='\033[0;31m'
|
||
GREEN='\033[0;32m'
|
||
YELLOW='\033[1;33m'
|
||
NC='\033[0m'
|
||
|
||
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||
log_err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||
|
||
die() {
|
||
log_err "$@"
|
||
exit 1
|
||
}
|
||
|
||
require_cmd() {
|
||
command -v "$1" >/dev/null 2>&1 || die "缺少命令: $1。$2"
|
||
}
|
||
|
||
# ---------- 前置检查 ----------
|
||
require_cmd ssh "macOS 系统自带即可。"
|
||
require_cmd rsync "macOS 系统自带即可。"
|
||
require_cmd sshpass "请执行: brew install hudochenkov/sshpass/sshpass"
|
||
|
||
[[ -f "$CRED_FILE" ]] || die "凭据文件不存在: $CRED_FILE"
|
||
[[ -d "$LOCAL_APP" ]] || die "本地后端不存在: $LOCAL_APP"
|
||
|
||
# ---------- 解析凭据 ----------
|
||
# 支持:
|
||
# ip: 1.2.3.4
|
||
# root账号:xxx / root密码:xxx / 密码:xxx
|
||
parse_credentials() {
|
||
local ip="" pass=""
|
||
local line key val
|
||
|
||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||
line="${line#"${line%%[![:space:]]*}"}"
|
||
line="${line%"${line##*[![:space:]]}"}"
|
||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||
|
||
# 勿用 [::] 字符类:bash 会把全角冒号与 : 当成范围,误匹配中文
|
||
if [[ "$line" == *:* ]]; then
|
||
key="${line%%:*}"
|
||
val="${line#*:}"
|
||
elif [[ "$line" == *:* ]]; then
|
||
key="${line%%:*}"
|
||
val="${line#*:}"
|
||
else
|
||
continue
|
||
fi
|
||
key="${key//[[:space:]]/}"
|
||
val="${val#"${val%%[![:space:]]*}"}"
|
||
val="${val%"${val##*[![:space:]]}"}"
|
||
|
||
case "$key" in
|
||
ip|IP|host|Host|HOST) ip="$val" ;;
|
||
root账号|root密码|密码|password|Password|PASSWORD|passwd) pass="$val" ;;
|
||
esac
|
||
done < "$CRED_FILE"
|
||
|
||
[[ -n "$ip" ]] || die "凭据文件未解析到 IP(期望行如: ip: 121.x.x.x)"
|
||
[[ -n "$pass" ]] || die "凭据文件未解析到密码(期望行如: root账号:***)"
|
||
|
||
REMOTE_HOST="$ip"
|
||
REMOTE_PASS="$pass"
|
||
}
|
||
|
||
parse_credentials
|
||
log_info "目标主机: ${SSH_USER}@${REMOTE_HOST}"
|
||
|
||
export SSHPASS="$REMOTE_PASS"
|
||
trap 'unset SSHPASS REMOTE_PASS 2>/dev/null || true' EXIT
|
||
|
||
remote_ssh() {
|
||
sshpass -e ssh "${SSH_OPTS[@]}" "${SSH_USER}@${REMOTE_HOST}" "$@"
|
||
}
|
||
|
||
remote_rsync() {
|
||
# 用法: remote_rsync [rsync-args...] src dest
|
||
sshpass -e rsync -e "ssh ${SSH_OPTS[*]}" "$@"
|
||
}
|
||
|
||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||
GIT_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||
log_info "发版时间戳: $TIMESTAMP commit: $GIT_SHA"
|
||
|
||
# ---------- 1. 本地构建前端 ----------
|
||
if [[ "$SKIP_BUILD" -eq 1 ]]; then
|
||
log_warn "已跳过 pnpm build,使用现有 dist"
|
||
else
|
||
require_cmd pnpm "请安装 pnpm: npm i -g pnpm"
|
||
[[ -f "$LOCAL_WEB_ADMIN/.env.production" ]] || log_warn "未找到 web-admin/.env.production,请确认生产构建配置正确"
|
||
log_info "开始构建前端: cd web-admin && pnpm build"
|
||
(
|
||
cd "$LOCAL_WEB_ADMIN"
|
||
pnpm build
|
||
)
|
||
fi
|
||
|
||
[[ -d "$LOCAL_DIST" ]] || die "前端产物不存在: $LOCAL_DIST(请先构建或去掉 --skip-build)"
|
||
[[ -f "$LOCAL_DIST/index.html" ]] || die "前端产物异常: 缺少 $LOCAL_DIST/index.html"
|
||
|
||
# ---------- 2. 备份远程 web ----------
|
||
log_info "备份远程 web → ${REMOTE_BAK}/web-${TIMESTAMP}"
|
||
remote_ssh "mkdir -p '${REMOTE_BAK}' && \
|
||
if [[ -d '${REMOTE_WEB}' ]]; then \
|
||
cp -a '${REMOTE_WEB}' '${REMOTE_BAK}/web-${TIMESTAMP}'; \
|
||
else \
|
||
echo '远程 web 目录不存在,跳过备份' >&2; \
|
||
exit 1; \
|
||
fi"
|
||
|
||
# ---------- 3. 同步前端 dist(保留远程 dist/src)----------
|
||
log_info "同步前端到 ${REMOTE_DIST}(rsync --delete,排除 src/)"
|
||
remote_ssh "mkdir -p '${REMOTE_DIST}'"
|
||
# --exclude 'src/':不同步也不删除远程 src
|
||
# --delete:删除远程 dist 中本地已不存在的构建文件(不含被 exclude 的 src)
|
||
remote_rsync -az --delete --exclude 'src/' --exclude '.DS_Store' \
|
||
"${LOCAL_DIST}/" \
|
||
"${SSH_USER}@${REMOTE_HOST}:${REMOTE_DIST}/"
|
||
|
||
remote_ssh "[[ -d '${REMOTE_DIST}/src' ]] && echo 'OK: dist/src 仍存在' || echo 'NOTE: 远程 dist/src 当前不存在(若业务依赖请检查)'"
|
||
log_info "前端同步完成"
|
||
|
||
# ---------- 4. 备份远程 backend ----------
|
||
log_info "备份远程 backend → ${REMOTE_BAK}/backend-${TIMESTAMP}"
|
||
remote_ssh "mkdir -p '${REMOTE_BAK}' && \
|
||
if [[ -d '${REMOTE_BACKEND}' ]]; then \
|
||
cp -a '${REMOTE_BACKEND}' '${REMOTE_BAK}/backend-${TIMESTAMP}'; \
|
||
else \
|
||
echo '远程 backend 目录不存在,跳过备份' >&2; \
|
||
exit 1; \
|
||
fi"
|
||
|
||
# ---------- 5. 替换 backend/app,并同步依赖清单 ----------
|
||
log_info "替换远程 ${REMOTE_BACKEND}/app"
|
||
remote_ssh "rm -rf '${REMOTE_BACKEND}/app'"
|
||
remote_rsync -az --delete \
|
||
--exclude '__pycache__/' \
|
||
--exclude '*.pyc' \
|
||
--exclude '.DS_Store' \
|
||
"${LOCAL_APP}/" \
|
||
"${SSH_USER}@${REMOTE_HOST}:${REMOTE_BACKEND}/app/"
|
||
if [[ -f "$REPO_ROOT/backend/requirements.txt" ]]; then
|
||
log_info "同步 requirements.txt 并安装依赖"
|
||
remote_rsync -az \
|
||
"$REPO_ROOT/backend/requirements.txt" \
|
||
"${SSH_USER}@${REMOTE_HOST}:${REMOTE_BACKEND}/requirements.txt"
|
||
remote_ssh "cd '${REMOTE_BACKEND}' && \
|
||
./venv/bin/pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple"
|
||
fi
|
||
log_info "后端 app 同步完成"
|
||
|
||
# ---------- 6. 重启后端 ----------
|
||
log_info "重启后端服务 (PORT=9111 WORKERS=1)"
|
||
remote_ssh "cd '${REMOTE_BACKEND}' && \
|
||
chmod +x ./start-prod.sh && \
|
||
PORT=9111 WORKERS=1 ENV_FILE=./.env.production ./start-prod.sh restart"
|
||
|
||
# ---------- 7. 健康检查(重启后进程可能尚未监听,重试数秒)----------
|
||
log_info "健康检查 /health"
|
||
HEALTH_OUT=""
|
||
for _i in 1 2 3 4 5 6 7 8 9 10; do
|
||
HEALTH_OUT="$(remote_ssh "curl -fsS --max-time 5 http://127.0.0.1:9111/health" 2>/dev/null || true)"
|
||
if [[ "$HEALTH_OUT" == *healthy* ]]; then
|
||
break
|
||
fi
|
||
sleep 2
|
||
done
|
||
if [[ "$HEALTH_OUT" == *healthy* ]]; then
|
||
log_info "健康检查通过: $HEALTH_OUT"
|
||
else
|
||
log_err "健康检查未通过,远程输出: ${HEALTH_OUT:-<empty>}"
|
||
log_warn "可手动排查: ssh ${SSH_USER}@${REMOTE_HOST} 'cd ${REMOTE_BACKEND} && ./start-prod.sh status|logs'"
|
||
log_warn "备份位置: ${REMOTE_BAK}/web-${TIMESTAMP} ${REMOTE_BAK}/backend-${TIMESTAMP}"
|
||
exit 1
|
||
fi
|
||
|
||
unset SSHPASS REMOTE_PASS
|
||
|
||
echo
|
||
log_info "发版成功"
|
||
echo " commit: $GIT_SHA"
|
||
echo " 前端备份: ${REMOTE_BAK}/web-${TIMESTAMP}"
|
||
echo " 后端备份: ${REMOTE_BAK}/backend-${TIMESTAMP}"
|
||
echo " 管理端: http://${REMOTE_HOST}:9911"
|
||
echo
|