#!/bin/bash # 生产环境部署脚本。 # # 运行约定: # - 默认从 Git 的 main 拉取,避免把 origin 误当成发布源。 # - DEPLOY_FRONTEND_DIR / FRONTEND_DIR 可指定 Nginx 实际提供的静态目录。 # - DEPLOY_HEALTHCHECK_URL 可指定 Node API 健康检查地址;未配置时依次探测 3001、3000。 # - 新版本启动或健康检查失败时,自动回滚到部署前的提交。 # - 同一时间只允许一个部署运行,避免多个 webhook 交叉 reset/restart。 set -Eeuo pipefail # 代码、依赖和 Nginx 静态文件必须能被 www/systemd 读取;密钥仍由 .env 的权限单独保护。 umask 022 SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" DEPLOY_DIR="${DEPLOY_DIR:-$SCRIPT_DIR}" if ! DEPLOY_DIR="$(cd -- "$DEPLOY_DIR" 2>/dev/null && pwd -P)"; then echo "[deploy] deployment directory is not accessible" exit 1 fi # 公开版不绑定生产仓库;先在私有部署环境中配置自己的 SSH 或 HTTPS 仓库地址。 DEPLOY_REPO_URL="${DEPLOY_REPO_URL:?请先配置自己的发布仓库 DEPLOY_REPO_URL}" DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}" DEPLOY_SERVICE="${DEPLOY_SERVICE:-wall}" DEPLOY_HEALTHCHECK_TIMEOUT="${DEPLOY_HEALTHCHECK_TIMEOUT:-10}" LOG_FILE="${DEPLOY_LOG_FILE:-$DEPLOY_DIR/logs/deploy.log}" DEPLOY_LOCK_PATH="${DEPLOY_LOCK_PATH:-$DEPLOY_DIR/.deploy.lock}" DEPLOY_STATUS_FILE="${DEPLOY_STATUS_FILE:-$DEPLOY_DIR/logs/deploy-status.json}" DATABASE_DIAGNOSTIC_FILE="$DEPLOY_DIR/logs/deploy-database-check.json" STATIC_CACHE_REPORT_FILE="$DEPLOY_DIR/logs/deploy-static-cache.json" DEPLOY_DATABASE_ATTEMPT_ID="${DEPLOY_DATABASE_ATTEMPT_ID:-$(date -u '+%s')-$$}" DEPLOY_STATE="starting" # git reset 后重新执行工作树里的新脚本,避免本次部署继续使用旧版本函数。 DEPLOY_REEXEC_AFTER_UPDATE="${DEPLOY_REEXEC_AFTER_UPDATE:-0}" DEPLOY_LOCK_HELD="${DEPLOY_LOCK_HELD:-0}" DEPLOY_LOCK_FALLBACK="${DEPLOY_LOCK_FALLBACK:-0}" DEPLOY_PREVIOUS_REV="${DEPLOY_PREVIOUS_REV:-}" # systemd 往往使用精简 PATH,面板安装的 Node.js 不一定能被 command -v 找到。 # 优先使用显式配置,其次使用当前 PATH,最后探测常见的宝塔 Node.js 安装目录。 NODE="${NODE_PATH_BIN:-$(command -v node 2>/dev/null || true)}" if [ -z "$NODE" ]; then for candidate in /www/server/nodejs/*/bin/node /usr/local/node*/bin/node; do if [ -x "$candidate" ]; then NODE="$candidate" break fi done fi NODE="${NODE:-node}" if [ -x "$NODE" ]; then NODE_BIN_DIR="$(dirname -- "$NODE")" case ":$PATH:" in *":$NODE_BIN_DIR:"*) ;; *) PATH="$NODE_BIN_DIR:$PATH"; export PATH ;; esac fi NPM="${NPM_PATH:-$(command -v npm 2>/dev/null || true)}" if [ -z "$NPM" ] && [ -x "$(dirname -- "$NODE")/npm" ]; then NPM="$(dirname -- "$NODE")/npm" fi NPM="${NPM:-npm}" ensure_tool() { local label="$1" local value="$2" if [ -x "$value" ] || command -v "$value" >/dev/null 2>&1; then return 0 fi echo "[deploy] $label is unavailable: $value" return 1 } # 兼容旧运维配置:旧配置若使用 FRONTEND_DIR,仍可作为 Nginx 静态目录。 DEPLOY_FRONTEND_DIR="${DEPLOY_FRONTEND_DIR:-${FRONTEND_DIR:-}}" DEPLOY_FRONTEND_SOURCE="${DEPLOY_FRONTEND_SOURCE:-$DEPLOY_DIR/frontend}" DEPLOY_FRONTEND_PARENT="${DEPLOY_FRONTEND_PARENT:-$(dirname -- "$DEPLOY_DIR")}" if [ -z "$DEPLOY_FRONTEND_DIR" ] && [ -d "$DEPLOY_DIR/../frontend" ] && [ "$DEPLOY_DIR/../frontend" != "$DEPLOY_FRONTEND_SOURCE" ]; then DEPLOY_FRONTEND_DIR="$(dirname -- "$DEPLOY_DIR")/frontend" fi DEPLOY_HOME_DIR="${DEPLOY_HOME_DIR:-${HOME:-/home/www}}" DEPLOY_SSH_CONFIG="${DEPLOY_SSH_CONFIG:-$DEPLOY_HOME_DIR/.ssh/config}" mkdir -p "$(dirname -- "$LOG_FILE")" "$(dirname -- "$DEPLOY_LOCK_PATH")" exec >> "$LOG_FILE" 2>&1 log_stamp() { date '+%Y-%m-%d %H:%M:%S' } write_status() { local state="$1" local revision="${2:-${TARGET_REV:-${PREVIOUS_REV:-}}}" local exit_code="${3:-}" local updated_at local temp_file local diagnostics="null" local static_cache="null" updated_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" temp_file="${DEPLOY_STATUS_FILE}.tmp.$$" mkdir -p "$(dirname -- "$DEPLOY_STATUS_FILE")" || return 0 if [ "$state" = "failed" ] && [ -f "$DATABASE_DIAGNOSTIC_FILE" ] && [ -f "$DEPLOY_DIR/scripts/check-database-startup.js" ]; then diagnostics="$("$NODE" "$DEPLOY_DIR/scripts/check-database-startup.js" --read-report "$DATABASE_DIAGNOSTIC_FILE" "$revision" "$DEPLOY_DATABASE_ATTEMPT_ID" 2>/dev/null)" || diagnostics="null" fi if [ -f "$STATIC_CACHE_REPORT_FILE" ] && [ -f "$DEPLOY_DIR/scripts/configure-static-cache.js" ]; then static_cache="$("$NODE" "$DEPLOY_DIR/scripts/configure-static-cache.js" --read-report "$STATIC_CACHE_REPORT_FILE" "$revision" 2>/dev/null)" || static_cache="null" fi if [ -n "$exit_code" ]; then printf '{"state":"%s","revision":"%s","updatedAt":"%s","exitCode":%s,"diagnostics":%s,"staticCache":%s}\n' \ "$state" "$revision" "$updated_at" "$exit_code" "$diagnostics" "$static_cache" > "$temp_file" else printf '{"state":"%s","revision":"%s","updatedAt":"%s"}\n' \ "$state" "$revision" "$updated_at" > "$temp_file" fi mv -f -- "$temp_file" "$DEPLOY_STATUS_FILE" || rm -f -- "$temp_file" } on_exit() { local status=$? if [ "$status" -ne 0 ] && [ "$DEPLOY_STATE" != "failed" ]; then DEPLOY_STATE="failed" write_status "$DEPLOY_STATE" "${TARGET_REV:-${PREVIOUS_REV:-}}" "$status" || true fi if [ "${FALLBACK_LOCK_HELD:-0}" = 1 ]; then rm -f -- "$DEPLOY_LOCK_DIR/pid" 2>/dev/null || true rmdir "$DEPLOY_LOCK_DIR" 2>/dev/null || true fi echo "[$(log_stamp)] deploy exit: $status" } trap on_exit EXIT echo "[$(log_stamp)] deploy start" write_status "$DEPLOY_STATE" "" "" || true ensure_tool node "$NODE" || exit 1 ensure_tool npm "$NPM" || exit 1 if ! "$NODE" -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 20 ? 0 : 1)' >/dev/null 2>&1; then echo "[deploy] Node.js 20 or newer is required" exit 1 fi # 不接受把凭据直接塞进远端 URL;否则 git 的错误输出可能把它带入部署日志。 case "$DEPLOY_REPO_URL" in http://*@*|https://*@*) echo "[deploy] refusing repository URL with embedded credentials" exit 1 ;; esac if [ -f "$DEPLOY_SSH_CONFIG" ]; then export GIT_SSH_COMMAND="ssh -F $DEPLOY_SSH_CONFIG" fi cd "$DEPLOY_DIR" || { echo "[deploy] deployment directory is not accessible" exit 1 } # flock 释放依赖进程退出;没有 flock 的系统使用原子目录锁,并在退出时清理。 # 重新执行新脚本时继承已持有的锁,不重复抢锁。 if [ "$DEPLOY_LOCK_HELD" = 1 ]; then if [ "$DEPLOY_LOCK_FALLBACK" = 1 ]; then DEPLOY_LOCK_DIR="${DEPLOY_LOCK_PATH}.d" FALLBACK_LOCK_HELD=1 fi elif command -v flock >/dev/null 2>&1; then exec 9>"$DEPLOY_LOCK_PATH" if ! flock -n 9; then echo "[deploy] another deployment is already running" exit 1 fi else DEPLOY_LOCK_DIR="${DEPLOY_LOCK_PATH}.d" FALLBACK_LOCK_HELD=0 acquire_fallback_lock() { if mkdir "$DEPLOY_LOCK_DIR" 2>/dev/null; then FALLBACK_LOCK_HELD=1 printf '%s\n' "$$" > "$DEPLOY_LOCK_DIR/pid" return 0 fi local owner_pid="" if [ -r "$DEPLOY_LOCK_DIR/pid" ]; then owner_pid="$(<"$DEPLOY_LOCK_DIR/pid")" fi if [ -n "$owner_pid" ] && kill -0 "$owner_pid" 2>/dev/null; then echo "[deploy] another deployment is already running" return 1 fi # 只移除确认没有存活进程的锁目录,不递归删除,避免误删部署目录。 if ! rm -f -- "$DEPLOY_LOCK_DIR/pid" || ! rmdir "$DEPLOY_LOCK_DIR" 2>/dev/null; then echo "[deploy] another deployment is already running" return 1 fi mkdir "$DEPLOY_LOCK_DIR" || return 1 FALLBACK_LOCK_HELD=1 printf '%s\n' "$$" > "$DEPLOY_LOCK_DIR/pid" } acquire_fallback_lock fi PREVIOUS_REV="${DEPLOY_PREVIOUS_REV:-$(git rev-parse HEAD 2>/dev/null || true)}" if [ -z "$PREVIOUS_REV" ]; then echo "[deploy] current checkout is not a git repository" exit 1 fi run_npm_install() { cd "$DEPLOY_DIR" || return 1 if [ -f package-lock.json ]; then # 锁文件存在时必须严格按锁文件安装。npm ci 失败通常意味着锁文件不一致, # 不能再用 npm install 改写依赖树后继续上线。 "$NPM" ci --omit=dev || return 1 else "$NPM" install --omit=dev || return 1 fi if [ -d "$DEPLOY_DIR/frontend" ] && [ -f "$DEPLOY_DIR/frontend/package.json" ]; then cd "$DEPLOY_DIR/frontend" if [ -f package-lock.json ]; then "$NPM" ci || return 1 else "$NPM" install || return 1 fi fi } check_frontend_mirror() { local checker="$DEPLOY_DIR/scripts/check-frontend-mirror.js" if [ ! -f "$checker" ]; then echo "[deploy] frontend/public mirror checker is missing" return 1 fi "$NODE" "$checker" || return 1 } check_frontend_mirror_for_rollback() { local checker="$DEPLOY_DIR/scripts/check-frontend-mirror.js" if [ ! -f "$checker" ]; then # 回滚到历史版本时,检查脚本可能尚未存在;不能因此阻断旧版本恢复。 echo "[deploy] legacy revision has no frontend mirror checker; skipping optional check" return 0 fi "$NODE" "$checker" || return 1 } sync_frontend() { if [ -z "$DEPLOY_FRONTEND_DIR" ] || [ "$DEPLOY_FRONTEND_DIR" = "$DEPLOY_FRONTEND_SOURCE" ]; then return 0 fi if [ ! -d "$DEPLOY_FRONTEND_SOURCE" ]; then echo "[deploy] frontend source not found: $DEPLOY_FRONTEND_SOURCE" return 1 fi local target_dir="${DEPLOY_FRONTEND_DIR%/}" local parent_dir local expected_dir local target_real if [ -z "$target_dir" ] || [ "$target_dir" = "/" ]; then echo "[deploy] refusing unsafe frontend target" return 1 fi case "$DEPLOY_FRONTEND_PARENT" in /*) ;; *) echo "[deploy] frontend parent must be an absolute path" return 1 ;; esac if [ ! -d "$DEPLOY_FRONTEND_PARENT" ]; then echo "[deploy] frontend parent directory does not exist" return 1 fi parent_dir="$(cd -- "$DEPLOY_FRONTEND_PARENT" && pwd -P)" || return 1 case "$parent_dir" in "/"|"$DEPLOY_DIR"|"$DEPLOY_DIR"/*) echo "[deploy] refusing frontend parent inside deployment directory" return 1 ;; esac expected_dir="$parent_dir/frontend" if [ "$target_dir" != "$expected_dir" ]; then echo "[deploy] frontend target must be the configured parent's frontend directory" return 1 fi if [ -e "$target_dir" ]; then target_real="$(cd -- "$target_dir" && pwd -P)" || return 1 if [ "$target_real" != "$expected_dir" ]; then echo "[deploy] refusing frontend target symlink outside configured parent" return 1 fi fi mkdir -p "$DEPLOY_FRONTEND_DIR" || return 1 if command -v rsync >/dev/null 2>&1; then rsync -a --delete -- "$DEPLOY_FRONTEND_SOURCE/" "$DEPLOY_FRONTEND_DIR/" || return 1 else # cp 没有删除语义;先清空已通过路径校验的静态目录,确保旧资源不会残留。 find "$DEPLOY_FRONTEND_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + || return 1 cp -a "$DEPLOY_FRONTEND_SOURCE/." "$DEPLOY_FRONTEND_DIR/" || return 1 fi } run_systemctl() { if [ "$(id -u)" -eq 0 ]; then systemctl "$@" return $? fi if ! command -v sudo >/dev/null 2>&1; then echo "[deploy] sudo unavailable for non-root service control" return 1 fi sudo -n systemctl "$@" } restart_service() { DEPLOY_RESTART_ATTEMPTED=1 if ! command -v systemctl >/dev/null 2>&1; then echo "[deploy] systemctl unavailable; refusing to report a deployment without restart" return 1 fi if ! run_systemctl restart "$DEPLOY_SERVICE"; then echo "[deploy] service restart failed" return 1 fi if ! run_systemctl is-active "$DEPLOY_SERVICE" >/dev/null 2>&1; then echo "[deploy] service is not active after restart" return 1 fi } check_url() { local url="$1" local body if ! command -v curl >/dev/null 2>&1; then echo "[deploy] curl unavailable; health check cannot run" return 1 fi if ! body="$(curl --fail --silent --show-error --max-time "$DEPLOY_HEALTHCHECK_TIMEOUT" "$url" 2>/dev/null)"; then echo "[deploy] health check request failed" return 1 fi if [[ "$url" == */api/site-info ]]; then echo "$body" | grep -Eq '"code"[[:space:]]*:[[:space:]]*200' || return 1 fi } health_check() { local api_url local public_url if [ -n "${DEPLOY_HEALTHCHECK_URL:-}" ]; then check_url "$DEPLOY_HEALTHCHECK_URL" || return 1 else # 历史生产配置使用 3001,源码默认端口为 3000;兼容两种配置。 check_url "http://127.0.0.1:${DEPLOY_PORT:-3001}/api/site-info" 2>/dev/null && api_url="ok" || api_url="" if [ -z "$api_url" ]; then check_url "http://127.0.0.1:${PORT:-3000}/api/site-info" || return 1 fi fi public_url="${DEPLOY_PUBLIC_HEALTHCHECK_URL:-}" if [ -n "$public_url" ]; then check_url "$public_url" || return 1 fi } wait_for_health() { local attempts="${DEPLOY_HEALTHCHECK_ATTEMPTS:-12}" local interval="${DEPLOY_HEALTHCHECK_INTERVAL:-2}" local attempt for ((attempt = 1; attempt <= attempts; attempt++)); do if health_check; then return 0 fi if [ "$attempt" -lt "$attempts" ]; then sleep "$interval" fi done return 1 } rollback() { local rollback_ok=1 echo "[deploy] rolling back to $PREVIOUS_REV" if git reset --hard "$PREVIOUS_REV" && run_npm_install && check_frontend_mirror_for_rollback && sync_frontend && restart_after_rollback && wait_for_health; then rollback_ok=0 echo "[deploy] rollback complete" else echo "[deploy] rollback health check failed; manual intervention required" fi return "$rollback_ok" } restart_after_rollback() { if [ "${DEPLOY_RESTART_ATTEMPTED:-0}" = 1 ]; then restart_service fi } if ! git check-ref-format --branch "$DEPLOY_BRANCH" >/dev/null 2>&1; then echo "[deploy] invalid deployment branch configuration" exit 1 fi DEPLOY_REMOTE_REF="refs/remotes/deploy/$DEPLOY_BRANCH" # 发布源是受信任的 Git main;主分支可能经过已审计的历史清理, # 因此必须允许服务器上的远端跟踪引用从旧历史切换到新历史。 if ! git fetch --prune "$DEPLOY_REPO_URL" "+refs/heads/$DEPLOY_BRANCH:$DEPLOY_REMOTE_REF"; then echo "[deploy] failed to fetch Git source" exit 1 fi TARGET_REV="$(git rev-parse "$DEPLOY_REMOTE_REF")" echo "[deploy] target revision: $TARGET_REV" DEPLOY_STATE="running" write_status "$DEPLOY_STATE" "$TARGET_REV" "" || true if ! git reset --hard "$TARGET_REV"; then echo "[deploy] failed to update checkout" if ! rollback; then exit 2 fi exit 1 fi # 当前 shell 已加载的是旧版本 deploy.sh;切换代码后重新 exec 新文件, # 并把原提交和锁状态传递过去,确保新逻辑真正参与本次部署。 if [ "$DEPLOY_REEXEC_AFTER_UPDATE" != 1 ]; then export DEPLOY_REEXEC_AFTER_UPDATE=1 export DEPLOY_LOCK_HELD=1 export DEPLOY_PREVIOUS_REV="$PREVIOUS_REV" export DEPLOY_DATABASE_ATTEMPT_ID if [ "${FALLBACK_LOCK_HELD:-0}" = 1 ]; then export DEPLOY_LOCK_FALLBACK=1 fi exec "${BASH:-/bin/bash}" "$DEPLOY_DIR/deploy.sh" fi check_database_startup() { # Validate migrations while the previous HTTP process still serves requests. # A failed preflight preserves that process and publishes only safe identifiers. "$NODE" "$DEPLOY_DIR/scripts/check-database-startup.js" "$DATABASE_DIAGNOSTIC_FILE" "$TARGET_REV" "$DEPLOY_DATABASE_ATTEMPT_ID" } if ! run_npm_install || ! check_frontend_mirror || ! check_database_startup || ! sync_frontend || ! restart_service || ! wait_for_health; then echo "[deploy] new version failed startup or health check" DEPLOY_STATE="failed" write_status "$DEPLOY_STATE" "$TARGET_REV" "1" || true if ! rollback; then exit 2 fi exit 1 fi # 部署进程只核对缓存,不修改 Nginx 或提升权限;管理员初始化需要显式 --apply。 # 应用部署状态与缓存状态分别记录,不能把前者当成 CDN 命中。 if ! "$NODE" "$DEPLOY_DIR/scripts/configure-static-cache.js" "$DEPLOY_DIR" "$DEPLOY_FRONTEND_DIR" "$STATIC_CACHE_REPORT_FILE" "$TARGET_REV"; then echo "[deploy] static cache inspection could not complete; application remains healthy" fi DEPLOY_STATE="success" write_status "$DEPLOY_STATE" "$TARGET_REV" "0" || true echo "[$(date '+%Y-%m-%d %H:%M:%S')] deploy complete: $TARGET_REV"