在 wait_health commit 追平通过后、清除 FAILED_STATE 之前,新增
check_core_apis 函数探测 4 个核心端点(/api/assets、/api/accounts、
/api/providers、/api/stats/overview),复用 deploy.sh 的端点列表与
判定标准(curl -w %{http_code},非 200 视为验收失败)。
API 探测失败时走现有完整回滚路径:git reset --hard $LOCAL +
systemctl restart + 二次 wait_health + 写 FAILED_STATE + 告警。
告警消息新增「失败原因」字段区分健康检查与 API 探测失败。
371 lines
16 KiB
Bash
Executable File
371 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# vps-manager 自动更新脚本(由 systemd timer 定时触发)
|
||
# 1. 自动同步 deploy/ 下的 systemd 单元文件(有变更才拷贝 + daemon-reload;
|
||
# timer 变更 enable --now,主服务 unit 变更额外 restart)
|
||
# 2. 从 Gitea 拉取最新代码,如有更新则装依赖并重启服务
|
||
# 3. 重启后健康闸门:轮询 /health 直到 commit 追平新 HEAD 且 status=ok;
|
||
# 超时或异常则回滚到 reset 前的提交、再次重启,并通过 .env 已配置渠道告警
|
||
#
|
||
# 可配置项(环境变量):
|
||
# VPS_MANAGER_DIR 项目根目录(默认 /opt/vps-manager)
|
||
# VPS_MANAGER_SVC systemd 服务名(默认 vps-manager)
|
||
# GIT_BRANCH 跟踪的远端分支(默认 main)
|
||
# DEPLOY_HEALTH_URL 健康检查地址(默认 http://127.0.0.1:8000/health)
|
||
# DEPLOY_HEALTH_TIMEOUT 追平新版本的轮询总超时秒数(默认 120)
|
||
# DEPLOY_HEALTH_INTERVAL 轮询间隔秒数(默认 15)
|
||
# DEPLOY_HEALTH_CURL_MAXTIME 单次请求超时秒数(默认 10)
|
||
# DEPLOY_ROLLBACK_TIMEOUT 回滚后确认恢复的超时秒数(默认 60)
|
||
#
|
||
# 使用示例:
|
||
# VPS_MANAGER_DIR=/srv/vps ./update.sh
|
||
# 或在 systemd unit 中设置 Environment=VPS_MANAGER_DIR=/srv/vps
|
||
|
||
set -euo pipefail
|
||
|
||
APP_DIR="${VPS_MANAGER_DIR:-/opt/vps-manager}"
|
||
SERVICE_NAME="${VPS_MANAGER_SVC:-vps-manager}"
|
||
BRANCH="${GIT_BRANCH:-main}"
|
||
|
||
cd "$APP_DIR"
|
||
|
||
# ---- 部署后健康闸门配置(均可用环境变量覆盖)----
|
||
# cc1 上服务由 systemd 拉起并监听 0.0.0.0:8000,本机直连 127.0.0.1 最稳(不依赖 Tailscale/证书)
|
||
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:8000/health}"
|
||
HEALTH_TIMEOUT="${DEPLOY_HEALTH_TIMEOUT:-120}" # 追平新版本的轮询总超时(秒),对齐 deploy-verification.md
|
||
HEALTH_INTERVAL="${DEPLOY_HEALTH_INTERVAL:-15}" # 轮询间隔(秒)
|
||
HEALTH_CURL_MAXTIME="${DEPLOY_HEALTH_CURL_MAXTIME:-10}" # 单次请求超时(秒)
|
||
ROLLBACK_TIMEOUT="${DEPLOY_ROLLBACK_TIMEOUT:-60}" # 回滚后确认服务恢复的超时(秒)
|
||
# 记录“上次因健康检查失败被回滚的远端提交”,避免每分钟 timer 反复部署同一坏版本导致
|
||
# 服务抖动 + 告警轰炸。放在 .git 目录内:不被 git 跟踪、不出现在 status、reset --hard 也不清除。
|
||
GIT_DIR_PATH="$(git rev-parse --absolute-git-dir 2>/dev/null || echo "$APP_DIR/.git")"
|
||
FAILED_STATE="$GIT_DIR_PATH/vps-manager-deploy-failed"
|
||
# JSON 解析用的 Python:优先 venv(cc1 必然存在),回退系统 python3;均不可用则跳过闸门(不误回滚)
|
||
PY=""
|
||
for _cand in ".venv/bin/python" "python3"; do
|
||
if "$_cand" -c 'import json' >/dev/null 2>&1; then PY="$_cand"; break; fi
|
||
done
|
||
# 健康取数优先用 curl(与 deploy.sh / deploy-verification.md 一致);cc1 万一没有 curl 则回退 Python urllib,
|
||
# 避免因取数工具缺失导致 /health 永远拿不到响应、误判超时回滚一个好版本。
|
||
HAS_CURL=0
|
||
if command -v curl >/dev/null 2>&1; then HAS_CURL=1; fi
|
||
|
||
# 输出当前运行版本号(语义版本 + git 短哈希),便于比对本地与线上是否一致
|
||
print_version() {
|
||
local semver commit
|
||
semver=$(cat VERSION 2>/dev/null | tr -d '[:space:]')
|
||
[ -z "$semver" ] && semver="dev"
|
||
commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||
echo "[$(date '+%F %T')] 当前版本: v${semver} (${commit})"
|
||
}
|
||
|
||
# 拉取 /health 响应体(失败或超时返回空串;|| true 避免 set -e 因 curl 非零退出而中断)
|
||
fetch_health() {
|
||
if [ "$HAS_CURL" = "1" ]; then
|
||
curl -s --max-time "$HEALTH_CURL_MAXTIME" "$HEALTH_URL" 2>/dev/null || true
|
||
elif [ -n "$PY" ]; then
|
||
"$PY" - "$HEALTH_URL" "$HEALTH_CURL_MAXTIME" <<'PYEOF' 2>/dev/null || true
|
||
import sys, urllib.request
|
||
try:
|
||
with urllib.request.urlopen(sys.argv[1], timeout=float(sys.argv[2])) as r:
|
||
sys.stdout.write(r.read().decode("utf-8", "replace"))
|
||
except Exception:
|
||
pass
|
||
PYEOF
|
||
fi
|
||
}
|
||
|
||
# 从 JSON 串提取字段值(解析失败返回空串)
|
||
health_field() {
|
||
printf '%s' "$1" | "$PY" -c "import json,sys; print(json.load(sys.stdin).get(sys.argv[1],''))" "$2" 2>/dev/null || true
|
||
}
|
||
|
||
# 轮询 /health 直到 status=ok 且 commit 追平 expected,或超时。返回 0=通过,1=未通过。
|
||
# expected 必须用 `git rev-parse --short HEAD` 计算,与 app.main._read_git_commit 完全一致,
|
||
# 否则缩写长度不同(--short 自适应 vs --short=7)会导致永不匹配、误触发回滚。
|
||
wait_health() {
|
||
local expected="$1" timeout="$2" deadline resp status commit
|
||
deadline=$(( $(date +%s) + timeout ))
|
||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||
resp="$(fetch_health)"
|
||
if [ -n "$resp" ]; then
|
||
status="$(health_field "$resp" status)"
|
||
commit="$(health_field "$resp" commit)"
|
||
if [ "$status" = "ok" ] && [ "$commit" = "$expected" ]; then
|
||
echo "[$(date '+%F %T')] 健康检查通过:status=ok commit=${commit}"
|
||
return 0
|
||
fi
|
||
echo "[$(date '+%F %T')] 健康检查中:status=${status:-?} commit=${commit:-?} 目标=${expected},${HEALTH_INTERVAL}s 后重试…"
|
||
else
|
||
echo "[$(date '+%F %T')] 健康检查中:${HEALTH_URL} 暂无响应,${HEALTH_INTERVAL}s 后重试…"
|
||
fi
|
||
sleep "$HEALTH_INTERVAL"
|
||
done
|
||
echo "[$(date '+%F %T')] 健康检查超时(${timeout}s)"
|
||
return 1
|
||
}
|
||
|
||
# 通过 .env 已配置的通知渠道(Telegram/邮件)发送告警:直接调用 app 的 notification_service,
|
||
# 用 venv Python 独立进程发送,不依赖 Web 服务是否存活;渠道未配置时自动跳过、不抛错。
|
||
send_alert() {
|
||
local msg="$1" py=".venv/bin/python"
|
||
if [ ! -x "$py" ]; then
|
||
echo "[$(date '+%F %T')] 未找到 .venv/bin/python,跳过告警发送(无法导入 app 通知服务)"
|
||
return 0
|
||
fi
|
||
echo "[$(date '+%F %T')] 发送部署告警…"
|
||
"$py" - "$msg" <<'PYEOF' 2>&1 || echo "[$(date '+%F %T')] 告警发送异常(渠道未配置或调用失败)"
|
||
import sys
|
||
sys.path.insert(0, ".")
|
||
try:
|
||
from app.services import notification_service
|
||
for r in notification_service.notify(sys.argv[1], subject="vps-manager 部署告警"):
|
||
print(f" 通知[{r.get('channel')}]: {'成功' if r.get('ok') else r.get('message')}")
|
||
except Exception as e: # noqa: BLE001
|
||
print(f" 通知调用失败: {e}")
|
||
sys.exit(1)
|
||
PYEOF
|
||
}
|
||
|
||
# 探测 4 个核心 API 端点,全部返回 HTTP 200 时返回 0,否则返回 1。
|
||
# 复用 deploy.sh 的端点列表与判定标准(非 200 视为验收失败)。
|
||
check_core_apis() {
|
||
local base="${HEALTH_URL%/health}"
|
||
local path code failed=0
|
||
for path in /api/assets /api/accounts /api/providers /api/stats/overview; do
|
||
if [ "$HAS_CURL" = "1" ]; then
|
||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$HEALTH_CURL_MAXTIME" "${base}${path}" 2>/dev/null || echo "000")
|
||
elif [ -n "$PY" ]; then
|
||
code=$("$PY" - "${base}${path}" "$HEALTH_CURL_MAXTIME" <<'PYEOF' 2>/dev/null || echo "000"
|
||
import sys, urllib.request
|
||
try:
|
||
with urllib.request.urlopen(sys.argv[1], timeout=float(sys.argv[2])) as r:
|
||
print(r.getcode())
|
||
except Exception:
|
||
pass
|
||
PYEOF
|
||
)
|
||
code="${code:-000}"
|
||
else
|
||
code="000"
|
||
fi
|
||
if [ "$code" = "200" ]; then
|
||
echo "[$(date '+%F %T')] ✓ ${path} -> ${code}"
|
||
else
|
||
echo "[$(date '+%F %T')] ❌ ${path} -> ${code}"
|
||
failed=1
|
||
fi
|
||
done
|
||
return "$failed"
|
||
}
|
||
|
||
# ---- 前端 JS 语法闸门:restart 前用 node --check 逐个校验自有 JS ----
|
||
# 只检查项目自有文件(app.js / api.js / modals.js / store.js / views/*.js),
|
||
# 排除第三方 vendored 文件(vue.global.prod.js / tailwind.js / chart.umd.js)。
|
||
# 保持零构建现状(raw JS + ?v=mtime 缓存失效),仅做最小语法校验。
|
||
check_js_syntax() {
|
||
local node_bin="" failed=0 f
|
||
# node 探测:优先 venv 内(如有),回退系统 PATH
|
||
for _cand in ".venv/bin/node" "node"; do
|
||
if command -v "$_cand" >/dev/null 2>&1; then node_bin="$_cand"; break; fi
|
||
done
|
||
if [ -z "$node_bin" ]; then
|
||
echo "[$(date '+%F %T')] ⚠️ 未找到 node,跳过 JS 语法闸门"
|
||
return 0
|
||
fi
|
||
echo "[$(date '+%F %T')] JS 语法闸门:使用 $node_bin"
|
||
# 自有 JS 列表(顶层固定文件 + views/ 下全部 .js)
|
||
local js_files=(
|
||
"static/js/app.js"
|
||
"static/js/api.js"
|
||
"static/js/modals.js"
|
||
"static/js/store.js"
|
||
)
|
||
for f in static/js/views/*.js; do
|
||
[ -f "$f" ] && js_files+=("$f")
|
||
done
|
||
for f in "${js_files[@]}"; do
|
||
[ -f "$f" ] || continue
|
||
if ! "$node_bin" --check "$f" 2>&1; then
|
||
echo "[$(date '+%F %T')] ❌ JS 语法错误: $f"
|
||
failed=1
|
||
else
|
||
echo "[$(date '+%F %T')] ✓ $f"
|
||
fi
|
||
done
|
||
return "$failed"
|
||
}
|
||
|
||
# 同步 systemd 单元文件:deploy/ 下的 .service/.timer 与 /etc/systemd/system
|
||
# 不一致时拷贝并 reload;timer 变更额外 enable --now(新调度周期立即生效且开机自启),
|
||
# 主服务 unit 变更额外 restart(否则光 daemon-reload 日志说已更新、实际没生效)。
|
||
# 每次都执行(而非仅版本更新时),保证单元变更不依赖代码变更触发。
|
||
sync_units() {
|
||
local changed=0 restart_main=0 f base
|
||
for f in "$APP_DIR"/deploy/*.service "$APP_DIR"/deploy/*.timer; do
|
||
[ -f "$f" ] || continue
|
||
base=$(basename "$f")
|
||
if ! cmp -s "$f" "/etc/systemd/system/$base"; then
|
||
cp "$f" "/etc/systemd/system/$base"
|
||
echo "[$(date '+%F %T')] 单元已更新: $base"
|
||
changed=1
|
||
case "$base" in
|
||
*.timer)
|
||
# enable --now:新增的 timer 只 restart 不会建开机自启软链,宿主重启后就丢了
|
||
systemctl enable --now "$base" 2>/dev/null \
|
||
|| systemctl restart "$base" 2>/dev/null || true
|
||
;;
|
||
"$SERVICE_NAME".service)
|
||
# 常驻服务的 unit 变更(如 ExecStart)光 daemon-reload 不生效,必须 restart;
|
||
# oneshot 服务下次触发自然读新配置,无需 restart
|
||
restart_main=1
|
||
;;
|
||
esac
|
||
fi
|
||
done
|
||
if [ "$changed" -eq 1 ]; then
|
||
systemctl daemon-reload
|
||
echo "[$(date '+%F %T')] systemd 单元同步完成"
|
||
fi
|
||
if [ "$restart_main" -eq 1 ]; then
|
||
systemctl restart "$SERVICE_NAME"
|
||
echo "[$(date '+%F %T')] 已重启 $SERVICE_NAME 使 unit 变更生效"
|
||
fi
|
||
}
|
||
|
||
sync_units
|
||
|
||
git fetch -q origin "$BRANCH"
|
||
|
||
LOCAL=$(git rev-parse HEAD)
|
||
REMOTE=$(git rev-parse "origin/$BRANCH")
|
||
|
||
if [ "$LOCAL" = "$REMOTE" ]; then
|
||
echo "[$(date '+%F %T')] 代码已是最新,无需更新"
|
||
print_version
|
||
exit 0
|
||
fi
|
||
|
||
# 若该远端提交此前已因健康检查失败被自动回滚,则不再重复部署同一坏版本:
|
||
# 否则每分钟 timer 都会 reset→重启→失败→回滚,服务反复抖动且告警轰炸。
|
||
# 等到有人推送修复提交(REMOTE 变化)后,此判断自然放行。
|
||
if [ -f "$FAILED_STATE" ] && [ "$(cat "$FAILED_STATE" 2>/dev/null || true)" = "$REMOTE" ]; then
|
||
echo "[$(date '+%F %T')] 远端 ${REMOTE:0:8} 此前健康检查失败已回滚,跳过重复部署(等待修复提交)"
|
||
print_version
|
||
exit 0
|
||
fi
|
||
|
||
echo "[$(date '+%F %T')] 检测到新版本: ${LOCAL:0:8} -> ${REMOTE:0:8}"
|
||
# 部署节点始终与远端完全一致:reset --hard 丢弃任何本地变更(含文件 mode 变化),
|
||
# 避免 cc1 上误改文件导致 pull 拒绝合并、更新链路卡死
|
||
git reset --hard -q "origin/$BRANCH"
|
||
|
||
# 依赖如有变更自动安装(无变化时 pip 会快速跳过)
|
||
if [ -x ".venv/bin/pip" ]; then
|
||
# 不再用 2>/dev/null || true 把报错全吞:装不上时只表现为服务重启后 ImportError,极难定位
|
||
if ! .venv/bin/pip install -q -r requirements.txt; then
|
||
echo "[$(date '+%F %T')] 警告:依赖安装失败,服务可能因 ImportError 起不来;请手动执行 .venv/bin/pip install -r requirements.txt 看完整报错"
|
||
fi
|
||
fi
|
||
|
||
# ---- 前端 JS 语法闸门 ----
|
||
if ! check_js_syntax; then
|
||
FAIL_REASON="前端 JS 语法检查失败(node --check 未通过,已中止上线)"
|
||
echo "[$(date '+%F %T')] ❌ ${FAIL_REASON},回滚至 ${LOCAL:0:8}"
|
||
git reset --hard -q "$LOCAL"
|
||
systemctl restart "$SERVICE_NAME" || true
|
||
echo "$REMOTE" > "$FAILED_STATE" 2>/dev/null || true
|
||
send_alert "🚨 vps-manager 部署失败:JS 语法闸门拦截
|
||
坏版本: ${REMOTE:0:8}
|
||
回滚至: ${LOCAL:0:8}
|
||
失败原因: ${FAIL_REASON}
|
||
时间: $(date '+%F %T')
|
||
主机: cc1:${APP_DIR}
|
||
排查: journalctl -u ${SERVICE_NAME} -n 50"
|
||
exit 1
|
||
fi
|
||
|
||
# ---- 测试闸门:restart 前用 pytest 拦截坏版本 ----
|
||
# 旧版本服务仍在运行(尚未 restart),测试失败时只需回退磁盘代码、不 restart,
|
||
# 避免坏版本上线。使用规则要求的 .venv/bin/python 解释器。
|
||
if [ -x ".venv/bin/python" ]; then
|
||
echo "[$(date '+%F %T')] 运行测试闸门(pytest -x -q)…"
|
||
if ! .venv/bin/python -m pytest tests/ -x -q 2>&1; then
|
||
FAIL_REASON="测试闸门失败(pytest -x -q 未通过),已阻止 restart"
|
||
echo "[$(date '+%F %T')] ❌ ${FAIL_REASON}"
|
||
# 回退磁盘代码到 reset 前的提交;旧版本服务仍在运行,无需 restart
|
||
git reset --hard -q "$LOCAL"
|
||
# sync_units 可能已 daemon-reload 过新 unit 文件,代码回退后需再 reload 一次对齐旧版本
|
||
systemctl daemon-reload 2>/dev/null || true
|
||
# 记录失败提交,阻止后续 timer 重复部署同一坏版本
|
||
echo "$REMOTE" > "$FAILED_STATE" 2>/dev/null || true
|
||
send_alert "🚨 vps-manager 测试闸门失败,已阻止部署
|
||
坏版本: ${REMOTE:0:8}
|
||
保留版本: ${LOCAL:0:8}
|
||
失败原因: ${FAIL_REASON}
|
||
时间: $(date '+%F %T')
|
||
主机: cc1:${APP_DIR}
|
||
排查: .venv/bin/python -m pytest tests/ -x -q"
|
||
exit 1
|
||
fi
|
||
echo "[$(date '+%F %T')] ✅ 测试闸门通过"
|
||
else
|
||
echo "[$(date '+%F %T')] ⚠️ 未找到 .venv/bin/python,跳过测试闸门"
|
||
fi
|
||
|
||
# 重启服务加载新代码
|
||
systemctl restart "$SERVICE_NAME"
|
||
echo "[$(date '+%F %T')] 已重启 $SERVICE_NAME 服务"
|
||
|
||
# ---- 部署后健康闸门:轮询 /health 直到 commit 追平新 HEAD 且 status=ok,否则回滚 ----
|
||
# LOCAL 是 reset 前记录的 HEAD,即回滚点;EXPECTED_SHORT 与 app 上报口径一致(--short 自适应)
|
||
if [ -z "$PY" ]; then
|
||
echo "[$(date '+%F %T')] ⚠️ 无可用 Python 解析 /health,跳过健康闸门(不回滚,请人工确认服务状态)"
|
||
print_version
|
||
exit 0
|
||
fi
|
||
EXPECTED_SHORT="$(git rev-parse --short HEAD)"
|
||
if wait_health "$EXPECTED_SHORT" "$HEALTH_TIMEOUT"; then
|
||
# /health commit 追平通过,进一步验证核心 API 端点可用性
|
||
if check_core_apis; then
|
||
rm -f "$FAILED_STATE" 2>/dev/null || true
|
||
echo "[$(date '+%F %T')] ✅ 部署健康检查通过(含核心 API 验证)"
|
||
print_version
|
||
exit 0
|
||
fi
|
||
# API 探测失败:视同健康检查未通过,进入回滚路径
|
||
FAIL_REASON="核心 API 探测失败(/health 已通过但部分端点非 200)"
|
||
echo "[$(date '+%F %T')] ❌ 核心 API 探测未通过,触发回滚"
|
||
else
|
||
FAIL_REASON="健康检查未通过(${HEALTH_TIMEOUT}s 内 commit 未追平 ${EXPECTED_SHORT} 或 status≠ok)"
|
||
fi
|
||
|
||
# 健康检查或 API 探测失败:回滚到 reset 前的提交并重启,尽量恢复到已知可用版本
|
||
echo "[$(date '+%F %T')] ❌ ${FAIL_REASON},回滚至 ${LOCAL:0:8}"
|
||
git reset --hard -q "$LOCAL"
|
||
systemctl restart "$SERVICE_NAME" || true
|
||
|
||
PREV_SHORT="$(git rev-parse --short HEAD)"
|
||
recovered="否(回滚后服务仍未通过健康检查,需人工介入)"
|
||
if wait_health "$PREV_SHORT" "$ROLLBACK_TIMEOUT"; then
|
||
recovered="是(已恢复到回滚版本)"
|
||
echo "[$(date '+%F %T')] 已回滚至 ${LOCAL:0:8},服务恢复正常"
|
||
else
|
||
echo "[$(date '+%F %T')] ⚠️ 已回滚至 ${LOCAL:0:8},但健康检查仍未通过,需人工介入"
|
||
fi
|
||
|
||
# 记录失败的远端提交,阻止后续 timer 重复部署同一坏版本(见文件头 FAILED_STATE 说明)
|
||
echo "$REMOTE" > "$FAILED_STATE" 2>/dev/null || true
|
||
|
||
# 通过 .env 已配置的通知渠道告警
|
||
send_alert "🚨 vps-manager 部署健康检查失败,已自动回滚
|
||
坏版本: ${REMOTE:0:8}
|
||
回滚至: ${LOCAL:0:8}
|
||
失败原因: ${FAIL_REASON}
|
||
服务恢复: ${recovered}
|
||
时间: $(date '+%F %T')
|
||
主机: cc1:${APP_DIR}
|
||
排查: journalctl -u ${SERVICE_NAME} -n 50"
|
||
|
||
# 以非 0 退出,让 systemd 记录本次更新失败(告警已发出)
|
||
exit 1
|