Files
ModelRouter/deploy.sh
JianFeeeee 8578696cc1 chore!: 移除厂商私有协议适配器与渠道业务插件(合规清理)
G-Star 合规要求下,把"模拟官方客户端、复刻私有协议、自动领取平台额度"这一类
能力整体移出仓库。这些实现依赖各平台未公开的端点、签名算法与官方 client id,
属于绕过客户端校验获取额度,风险高于普通协议适配。

移除(93 个文件):
- 适配器:opencodezen(伪装 UA 取免费池)、trae、buddy、kimicode
- 插件:channels.lua(12 个平台登录/领额度/签到)、quota.lua(厂商额度探针)
- 随之删除的 78 个渠道/额度专用测试与 3 份渠道文档

同时修掉的既有问题:
- **硬编码第三方 OAuth client_secret**(Google Cloud Code 的 GOCSPX-…,两处)
  随 channels.lua 一起消失。
- `deploy.sh` 的 `sync_plugins` 整段删除:它仍按"内置插件是发行物"的旧前提
  把插件复制/覆盖进 plugin_dir,与"插件属运维资产、网关不安装不覆盖"的契约
  冲突。现在部署脚本只同步适配器与二进制/配置。
- 适配器清单去掉已移除项;e2e 配置改用显式 `auto:` 链(`models.priority` 早已
  移除,该测试此前已失效)。

保留(公开协议):openai / anthropic / gemini / deepseek / github / groq /
mistral / ollama / qwen38 / sensenova = 10 个适配器;插件示例 billing / usage。

验证:go build -tags luajit ./... OK;internal/lua 21.4s、provider、core 51s、
gateway 122.8s、e2e 9.0s 全绿。
2026-10-27 09:23:24 +08:00

599 lines
26 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# ============================================================
# modelrouter 原子化滚动部署脚本
#
# 策略:
# 1. /tmp (tmpfs) 构建,/usr/local/bin (ext4) 部署
# 2. 构建后先 copy 到目标文件系统(.modelrouter.tmp),
# 再 rename(2) 做原子替换(同一文件系统内 mv 是原子的)
# 3. runtime.json 不替换(保留运行时密钥、审计状态)
# 4. systemctl restart → 服务中断仅在 Go 进程重启瞬间 (<500ms)
# 5. 健康检查确认新进程正常
#
# 配置文件(2026-09-05 起):
# 本脚本不再假设 config.yaml 与部署无关。实测教训:给源加 headers 时
# 漏删原有的 `headers: {}` → YAML 重复键 → 进程启动即退出 → 健康检查
# 失败 → 旧的 rollback 只恢复二进制,配置错误仍在,于是旧二进制也起不来,
# 服务陷入 systemd 重启循环(当时累计 446 次)。
# 因此:
# - 支持 --config <file> 投放一份待部署配置(原子 rename 到 TARGET_CONFIG)
# - 重启前先用新二进制 `-check` 预检配置,不合法就地失败,绝不重启服务
# - rollback 同时恢复二进制与配置文件,回滚后再次 -check 才重启
#
# 注意事项:
# - 本服务依赖自身(推理依赖),restart 前后会短暂中断,
# 但重试机制(systemd RestartSec=5 + 客户端 retry)可自愈
# - 适配器文件替换后需重启才能生效(VM 启动时批量加载)
# ============================================================
set -euo pipefail
# ---------- 配置 ----------
readonly TARGET_BIN="/usr/local/bin/modelrouter"
readonly TARGET_CONFIG="/etc/modelrouter/config.yaml"
readonly TARGET_ADAPTERS="/etc/modelrouter/adapters"
readonly TARGET_PLUGINS="/etc/modelrouter/plugins"
# 仓库所在目录不等于产品名:这里保持真实路径,除非用 MODELROUTER_REPO_DIR 覆盖。
readonly REPO_DIR="${MODELROUTER_REPO_DIR:-/home/program/llmsproxy}"
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
readonly BIN_NAME="modelrouter"
# 构建目录。默认落在仓库下的构建工作区,而不是 /tmp:本机 /tmp 是 8.8G
# tmpfs,实测被 Go 构建缓存占满后会让 go test/build 在链接阶段直接失败。
# 可用 MODELROUTER_BUILD_DIR 覆盖;同一目录也被测试用作 GOTMPDIR。
readonly BUILD_SUBDIR=".build-work"
if [[ -n "${MODELROUTER_BUILD_DIR:-}" ]]; then
readonly TMP_DIR="${MODELROUTER_BUILD_DIR}"
else
readonly TMP_DIR="${REPO_DIR}/${BUILD_SUBDIR}"
fi
# 同文件系统暂存文件(用于原子 rename)
readonly STAGING_BIN="${TARGET_BIN}.staging"
readonly STAGING_CONFIG="${TARGET_CONFIG}.staging"
# 回滚备份:健康检查失败时从这里恢复旧二进制 / 旧配置
readonly BACKUP_BIN="${TARGET_BIN}.bak"
readonly BACKUP_CONFIG="${TARGET_CONFIG}.rollback.bak"
# 待部署配置(--config <file> 指定;为空 = 不改动线上配置)
NEW_CONFIG_SRC=""
usage() {
cat <<'EOF'
用法: ./deploy.sh [--config <file>]
--config <file> 投放这份配置到 /etc/modelrouter/config.yaml(原子替换)。
省略时不改动线上配置,只部署二进制与适配器。
无论是否指定,重启前都会用新二进制 -check 预检线上配置。
流程: 构建 → 适配器同步 → 备份(二进制+配置) → 原子替换 → 配置预检
→ 重启 → 健康检查 → 失败则回滚(二进制+配置)并重启
EOF
}
parse_args() {
while (( $# > 0 )); do
case "$1" in
--config)
[[ $# -ge 2 ]] || { echo "--config 需要一个文件参数" >&2; exit 2; }
NEW_CONFIG_SRC="$2"
shift 2
;;
-h|--help) usage; exit 0 ;;
*) echo "未知参数: $1" >&2; usage; exit 2 ;;
esac
done
}
# ---------- 日志 ----------
log() { printf '\033[1;34m[%s]\033[0m %s\n' "$(date +%H:%M:%S)" "$*"; }
warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
err() { printf '\033[1;31m[ERR]\033[0m %s\n' "$*" >&2; }
fail() { err "$@"; exit 1; }
# ---------- 清理 ----------
cleanup() {
log "清理临时文件"
rm -rf "$TMP_DIR" "$STAGING_BIN" "$STAGING_CONFIG"
}
trap cleanup EXIT
# ---------- 前置检查 ----------
precheck() {
log "前置检查"
[[ -f "$REPO_DIR/go.mod" ]] || fail "仓库目录 $REPO_DIR 不存在"
command -v go &>/dev/null || fail "未找到 go"
command -v systemctl &>/dev/null || fail "未找到 systemctl"
[[ $(id -u) -eq 0 ]] || fail "需要 root 权限"
# 改名保护:本脚本现在部署到 /etc/modelrouter。若机器上仍是改名前的
# /etc/llmsproxy 且新路径尚未建立,直接继续会创建一份**空配置**并让新
# 服务在没有 key/源的情况下起来 —— 看起来成功,实际网关拒掉所有请求。
if [[ -d /etc/llmsproxy && ! -e "$TARGET_CONFIG" ]]; then
fail "检测到改名前的部署 /etc/llmsproxy,但 $TARGET_CONFIG 不存在。
先迁移(dry-run 默认,确认后加 --apply):
sudo ./packaging/migrate-to-modelrouter.sh
sudo ./packaging/migrate-to-modelrouter.sh --apply
如需全新部署,先把旧目录移开:mv /etc/llmsproxy /etc/llmsproxy.bak"
fi
# unit 与二进制路径必须一致。包安装把二进制放 /usr/bin,源码部署放
# /usr/local/bin;unit 里写错一行,systemctl restart 只会给 203/EXEC,
# 看起来像"部署成功但服务起不来"。此处提前拦住。
local unit="/etc/systemd/system/modelrouter.service"
if [[ -f "$unit" ]]; then
local unit_exe
unit_exe=$(awk -F'=' '/^ExecStart=/{print $2; exit}' "$unit" | awk '{print $1}')
if [[ -n "$unit_exe" && "$unit_exe" != "$TARGET_BIN" && ! -x "$unit_exe" ]]; then
fail "unit 的 ExecStart 指向 $unit_exe,但该文件不存在。
包安装(deb/rpm)用 /usr/bin/modelrouter;源码部署(本脚本)用 $TARGET_BIN。
请把 $unit 的 ExecStart 改成 $TARGET_BIN,或改用包安装。"
fi
if [[ -n "$unit_exe" && "$unit_exe" != "$TARGET_BIN" ]]; then
warn "unit ExecStart=$unit_exe,而本脚本部署到 $TARGET_BIN(两者都存在,确认哪个该生效)"
fi
fi
if [[ -n "$NEW_CONFIG_SRC" ]]; then
[[ -f "$NEW_CONFIG_SRC" ]] || fail "待部署配置不存在: $NEW_CONFIG_SRC"
# 同一文件比对:--config 指向线上文件本身时没有"投放"动作可做,
# 但仍然会走重启前的 -check 预检。
if [[ "$(readlink -f "$NEW_CONFIG_SRC")" == "$(readlink -f "$TARGET_CONFIG" 2>/dev/null)" ]]; then
warn "--config 指向线上配置本身,跳过投放(仍会预检)"
NEW_CONFIG_SRC=""
else
log "待部署配置: $NEW_CONFIG_SRC → $TARGET_CONFIG"
fi
fi
OLD_SIZE=$(stat -c%s "$TARGET_BIN" 2>/dev/null || echo 0)
OLD_SHA256=$(sha256sum "$TARGET_BIN" 2>/dev/null | awk '{print $1}')
log "旧二进制: $TARGET_BIN (${OLD_SIZE} bytes, sha256=${OLD_SHA256:0:16})"
log "go: $(go version)"
}
# ---------- 备份旧二进制 + 旧配置(用于回滚)----------
# 配置也必须备份:启动失败常常是配置问题,只回滚二进制会让旧二进制
# 继续吃坏配置,服务照样起不来。
backup_old_state() {
log "备份旧二进制到 $BACKUP_BIN"
if [[ -f "$TARGET_BIN" ]]; then
cp -f "$TARGET_BIN" "$BACKUP_BIN"
log "OK 旧二进制已备份 (sha256=$(sha256sum "$BACKUP_BIN" | awk '{print $1}' | cut -c1-16))"
else
warn "旧二进制不存在,跳过备份(无回滚点)"
fi
if [[ -f "$TARGET_CONFIG" ]]; then
cp -f "$TARGET_CONFIG" "$BACKUP_CONFIG"
log "OK 旧配置已备份到 $BACKUP_CONFIG (sha256=$(sha256sum "$BACKUP_CONFIG" | awk '{print $1}' | cut -c1-16))"
else
warn "线上配置不存在: $TARGET_CONFIG(首次部署?无配置回滚点)"
fi
}
# ---------- 构建 ----------
build() {
log "构建新二进制"
rm -rf "$TMP_DIR"
mkdir -p "$TMP_DIR/bin"
cd "$REPO_DIR"
CGO_ENABLED=1 GOTMPDIR="$TMP_DIR" go build -tags luajit \
-trimpath \
-ldflags="-s -w" \
-o "$TMP_DIR/bin/$BIN_NAME" \
./cmd/modelrouter
NEW_BIN="$TMP_DIR/bin/$BIN_NAME"
NEW_SIZE=$(stat -c%s "$NEW_BIN")
if [[ "$NEW_SIZE" -eq "$OLD_SIZE" ]]; then
NEW_SHA256=$(sha256sum "$NEW_BIN" | awk '{print $1}')
if [[ "$NEW_SHA256" == "$OLD_SHA256" ]]; then
# 二进制无变化。只有在同时也没有待部署配置时才能直接退出;
# 否则会造成“--config 指定了新配置却被静默丢弃”。
if [[ -z "$NEW_CONFIG_SRC" ]]; then
warn "新二进制与旧版本完全一致(sha256 相同)且无待部署配置,跳过部署"
exit 0
fi
log "二进制无变化(sha256 相同),但有待部署配置,继续"
fi
fi
log "新二进制: $NEW_BIN (${NEW_SIZE} bytes)"
# 验证:确认包含关键修复
local checks=(
"mergeUsage"
"prompt_tokens"
"completion_tokens"
"message_start"
)
for c in "${checks[@]}"; do
if strings "$NEW_BIN" | grep -qF "$c"; then
log " OK 新二进制包含: $c"
else
warn " 注意: 新二进制不包含: $c(非预期但非致命)"
fi
done
}
# ---------- 原子替换二进制 ----------
# 原理:
# /tmp 是 tmpfs,/usr/local/bin 是 ext4——不同文件系统 rename(2) 不原子。
# 因此先 copy 到目标文件系统上的 .staging 文件,再 rename(2) 到目标路径。
# rename(2) 在同一文件系统内是元数据级操作,对正在读写的进程安全:
# - 旧 inode 的 fd 继续有效(进程持有旧文件,不会读到截断内容)
# - 新进程打开 path 看到新 inode
# - 旧 inode 在最后一个 fd 关闭后释放
atomic_replace_binary() {
log "原子替换二进制"
local new_bin_tmp="$TMP_DIR/bin/$BIN_NAME"
[[ -f "$new_bin_tmp" ]] || fail "新二进制文件不存在: $new_bin_tmp"
# Step 1: copy 到目标文件系统(非原子,但目标文件唯一)
log " copy → $STAGING_BIN (同文件系统暂存)"
cp -f "$new_bin_tmp" "$STAGING_BIN"
chmod 0755 "$STAGING_BIN"
# Step 2: 原子 rename(同一文件系统,元数据级操作)
log " rename → $TARGET_BIN (原子)"
mv -f "$STAGING_BIN" "$TARGET_BIN"
# 验证
NEW_SHA256=$(sha256sum "$TARGET_BIN" | awk '{print $1}')
log "OK 二进制已原子替换 (sha256=${NEW_SHA256:0:16})"
}
# ---------- 原子替换配置 ----------
# 与二进制同一套 copy→rename 手法:先落到 /etc 上的 .staging,再 rename(2)。
# 只有 --config 指定了来源时才动线上配置。
atomic_replace_config() {
[[ -n "$NEW_CONFIG_SRC" ]] || return 0
log "原子替换配置"
log " copy → $STAGING_CONFIG (同文件系统暂存)"
cp -f "$NEW_CONFIG_SRC" "$STAGING_CONFIG"
chmod 0644 "$STAGING_CONFIG"
log " rename → $TARGET_CONFIG (原子)"
mv -f "$STAGING_CONFIG" "$TARGET_CONFIG"
log "OK 配置已原子替换 (sha256=$(sha256sum "$TARGET_CONFIG" | awk '{print $1}' | cut -c1-16))"
}
# ---------- 配置预检 ----------
# 用刚部署的新二进制解析线上配置。`-check` 只做 parse + ApplyDefaults,
# 不启动 Lua VM、不碰 runtime.json、不绑端口,因此可以安全地在服务仍在
# 运行时执行。不合法就在重启前失败——这正是上次事故缺的那一步。
verify_config() {
log "配置预检(新二进制 -check)"
[[ -f "$TARGET_CONFIG" ]] || fail "线上配置不存在: $TARGET_CONFIG"
local out
if out=$("$TARGET_BIN" -check -config "$TARGET_CONFIG" 2>&1); then
log "OK 配置合法"
return 0
fi
err "配置预检失败,未重启服务(线上进程仍在跑旧配置):"
printf '%s\n' "$out" >&2
return 1
}
# ---------- 密钥可解封校验 ----------
# config.yaml 里的凭据以 enc:v1: 密文落盘,解密钥匙是 master.key(或
# MODELROUTER_MASTER_KEY)。一旦钥匙丢失/错位,配置在**启动时**才会报错,
# 那时服务已经处于「二进制已替换 + 配置已替换」的半死状态,只能靠回滚。
# 所以在替换任何东西之前先验:能不能用现有的钥匙解开现有的密文。
#
# 两种情形要分开:
# 1. 配置里**有**密文 ⇒ 钥匙必须能解开(否则拒绝部署)
# 2. 配置里**没有**密文 ⇒ 全新安装/首次加密,无需钥匙(但会顺手记一条,
# 因为它意味着这次部署会把明文写成密文,而钥匙必须同时就位)
verify_master_key() {
log "密钥可解封校验"
[[ -f "$TARGET_CONFIG" ]] || return 0 # 配置都不在,交给 verify_config 报错
local sealed
sealed=$(grep -c 'enc:v1:' "$TARGET_CONFIG" 2>/dev/null || echo 0)
if [[ "$sealed" -eq 0 ]]; then
warn "配置内无密文(尚未加密或全为明文)——首次加密需要 master.key 可写"
return 0
fi
# 钥匙来源与 config 包一致:env 优先,否则 runtime_file 同目录的 master.key
local runtime_file key_file
runtime_file=$(awk '/^runtime_file:/{print $2; exit}' "$TARGET_CONFIG")
[[ -n "$runtime_file" ]] || runtime_file="$(dirname "$TARGET_CONFIG")/runtime.json"
key_file="$(dirname "$runtime_file")/master.key"
MASTER_KEY_ENV="${MODELROUTER_MASTER_KEY:-${LLMS_PROXY_MASTER_KEY:-}}"
if [[ -n "$MASTER_KEY_ENV" ]]; then
log " 钥匙来源: MODELROUTER_MASTER_KEY(环境变量)"
# 走真实解密路径验证,而不是只检查格式
if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then
log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
return 0
fi
err "凭据无法用 MODELROUTER_MASTER_KEY 解封,未替换任何文件:"
printf '%s\n' "$out" >&2
return 1
fi
if [[ ! -f "$key_file" ]]; then
err "配置含 $sealed 处密文,但钥匙文件不存在: $key_file"
err " 没有它,部署后服务将无法启动。恢复 master.key 或设置 MODELROUTER_MASTER_KEY 后重试。"
return 1
fi
log " 钥匙文件: $key_file ($(stat -c '%A' "$key_file" 2>/dev/null || echo '?'),$(wc -c < "$key_file") 字节)"
if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then
log "OK 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)"
return 0
fi
err "凭据无法解密(钥匙与配置不匹配),未替换任何文件:"
printf '%s\n' "$out" | head -5 >&2
err " 密钥来源: $key_file"
err " 常见原因:master.key 被重新生成、恢复了不匹配的备份,或换了机器。"
return 1
}
# ---------- 备份目录保留策略 ----------
# 每次部署都新建一个 <prefix>.bak.<时间戳> 目录,而回滚只用到最近一次
# ($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人清理,
# 实测线上已积累 71 个,/etc/modelrouter 因此涨到 122M。
#
# 保留最近 KEEP_BACKUPS 份足够回滚,同时给目录数设上限——否则一次误配置
# 就可能在几秒内造出成百上千个目录。
# 只删名字严格匹配 <prefix>.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
#
# ★ 必须**同时覆盖 adapters 与 plugins**(同一缺陷的两个实例):
# 本函数最初只处理 adapters.bak,而 sync_plugins 同样每次建 plugins.bak.*
# —— 于是修了 adapters 的堆积、plugins 继续涨(实测线上 36 个、占用 192M)。
# 这正是「两处只修一处」的老形状:给一个新前缀加备份目录时,必须同步纳入
# prune_all_backups,否则它就是一个不会被清理的新泄漏点。
KEEP_BACKUPS=5
prune_backups() {
local prefix="$1"
local base="/etc/modelrouter"
[[ -d "$base" ]] || return 0
# 防误用:前缀必须是已知的两个(避免有人传空串删掉整个目录)
case "$prefix" in
adapters|plugins) ;;
*) fail "prune_backups: 未知前缀 '$prefix'(只接受 adapters|plugins)" ;;
esac
# 先按数量上限硬裁:即使时间戳排序失效也不会无上限增长。
local all
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
local cap=$((KEEP_BACKUPS * 4))
if (( ${#all[@]} > cap )); then
warn "$prefix 备份目录有 ${#all[@]} 个(异常),裁到 $cap"
local i=0
for d in "${all[@]}"; do
i=$((i + 1))
# 从最旧的开始删(sort 后升序)。名字不规范的跳过不删。
if (( i <= ${#all[@]} - cap )); then
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
rm -rf "${base:?}/$d"
else
warn "跳过名字不规范的备份目录(不删): $d"
fi
fi
done
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
fi
# 再按时间保留最近 KEEP_BACKUPS 份(sort 后最新在末尾)。
local total=${#all[@]} i=0
for d in "${all[@]}"; do
i=$((i + 1))
# i <= total-KEEP 的是较旧的,要删。
if (( i <= total - KEEP_BACKUPS )); then
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
rm -rf "${base:?}/$d"
else
warn "跳过名字不规范的备份目录(不删): $d"
fi
fi
done
local left
left=$(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" | wc -l)
log " $prefix 备份保留最近 $KEEP_BACKUPS 份(当前剩 $left 个)"
}
# prune_all_backups:两个前缀都裁。
# ★ 新增任何 .bak.<时间戳> 目录的前缀时,必须在这里补上 —— 否则又一个泄漏点。
prune_all_backups() {
prune_backups adapters
prune_backups plugins
}
# ---------- 同步适配器 ----------
sync_adapters() {
log "同步适配器"
# 只覆盖内置适配器(与 internal/lua/adapters 同名的文件),
# 保留目录里其它运行时上传的 .lua —— WebUI 上传的自定义适配器
# 必须跨部署存活,清空会静默移除线上源依赖的适配器。
SRC_ADAPTERS="$REPO_DIR/internal/lua/adapters"
[[ -d "$SRC_ADAPTERS" ]] || fail "内嵌适配器目录不存在: $SRC_ADAPTERS"
# 备份旧的适配器文件(含任何运行时覆盖版本,如 opencode.lua)
BACKUP_DIR="/etc/modelrouter/adapters.bak.$(date +%Y%m%d%H%M%S)"
if [[ -d "$TARGET_ADAPTERS" ]]; then
mkdir -p "$BACKUP_DIR"
cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true
log " 旧适配器已备份到 $BACKUP_DIR"
fi
prune_all_backups
cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/"
chmod 0644 "$TARGET_ADAPTERS/"*.lua
# 校验:每个适配器必须包含 usage 透传修复(opencode/openai/deepseek 等应有 'uses')
local required_files=(openai deepseek anthropic gemini ollama github groq mistral sensenova qwen38)
for f in "${required_files[@]}"; do
[[ -f "$TARGET_ADAPTERS/$f.lua" ]] || warn " 缺少适配器: $f.lua"
done
local usage_fixed=0
for f in openai deepseek github groq mistral; do
grep -qE '\buses\b' "$TARGET_ADAPTERS/$f.lua" 2>/dev/null && ((usage_fixed++)) || true
done
log " usage 透传修复适配器: ${usage_fixed}/5 (openai/deepseek/github/groq/mistral)"
log "OK 适配器已同步到 $TARGET_ADAPTERS/"
}
# 插件不在这里同步。
#
# 早期实现会把内置插件复制/覆盖到 plugin_dir,理由是"内置插件是发行物的一部分"。
# 该前提已被废止:插件不再编入二进制、不进发行包、不自动安装,属于运维自有资产。
# 部署脚本只负责适配器(协议转换,随内核发布)与二进制/配置,绝不创建、覆盖或
# 删除 plugin_dir 里的任何文件。
# ---------- 重启服务 ----------
restart_service() {
log "重启服务(中断 <500ms)"
systemctl restart modelrouter.service || fail "systemd restart 失败"
log "OK modelrouter.service restarted"
}
# ---------- 健康检查 ----------
healthcheck() {
log "健康检查"
local attempts=20
local wait_sec=1
local attempt=1
while (( attempt <= attempts )); do
# 用 curl 检查(200=有 key 未鉴权但服务正常,401=需要认证,都说明服务在运行)
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 "$HEALTH_URL" 2>/dev/null || echo "000")
if [[ "$code" == "200" || "$code" == "401" ]]; then
log "OK 服务健康 (status=$code, ${attempt}/${attempts})"
return 0
fi
log " 等待中 ($attempt/$attempts, status=$code)"
sleep "$wait_sec"
(( attempt++ ))
done
warn "健康检查未通过,请检查服务状态: systemctl status modelrouter.service"
return 1
}
# ---------- 回滚 ----------
# 健康检查失败时恢复旧二进制**和旧配置**并重启,保证推理服务可用。
# 这是最后一道保险:即使新版本启动失败(编译错误、配置不兼容、
# panic on start 等),也能让旧版本继续服务,避免推理能力丢失。
#
# 为什么必须一并回滚配置:启动失败最常见的原因就是配置本身不合法
# (实测:YAML 重复键)。只恢复二进制的话,旧二进制照样解析不了坏配置,
# 服务继续在 systemd 重启循环里空转。
rollback() {
err "健康检查失败,启动回滚流程"
local restored_any=0
if [[ -f "$BACKUP_BIN" ]]; then
log "恢复旧二进制"
cp -f "$BACKUP_BIN" "$TARGET_BIN"
chmod 0755 "$TARGET_BIN"
restored_any=1
else
err "无二进制回滚备份 ($BACKUP_BIN 不存在)"
fi
# 只在本次真的换过配置时才回滚配置,避免把用户在部署期间
# 通过 WebUI 做的合法改动一起抹掉。
if [[ -n "$NEW_CONFIG_SRC" && -f "$BACKUP_CONFIG" ]]; then
log "恢复旧配置"
cp -f "$BACKUP_CONFIG" "$TARGET_CONFIG"
chmod 0644 "$TARGET_CONFIG"
restored_any=1
fi
if (( restored_any == 0 )); then
err "无任何回滚点,无法自动恢复"
err "请手动检查: systemctl status modelrouter.service && journalctl -u modelrouter -n 50"
return 1
fi
# 回滚后先验证恢复出来的组合真的能解析,再重启——否则只是把
# 重启循环换个二进制继续跑。
if ! "$TARGET_BIN" -check -config "$TARGET_CONFIG" >/dev/null 2>&1; then
err "回滚后的配置仍不合法,拒绝重启(避免 systemd 重启风暴)"
err " 手动修复 $TARGET_CONFIG 后执行: $TARGET_BIN -check -config $TARGET_CONFIG"
err " 备份可用: 二进制=$BACKUP_BIN 配置=$BACKUP_CONFIG"
return 1
fi
log "重启服务(旧版本)"
systemctl restart modelrouter.service || {
err "重启失败,服务可能完全宕机"
err "请手动检查: systemctl status modelrouter.service"
return 1
}
# 回滚后再健康检查(只查 1 次,失败就认了)
sleep 2
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$HEALTH_URL" 2>/dev/null || echo "000")
if [[ "$code" == "200" || "$code" == "401" ]]; then
log "OK 回滚成功,旧版本已恢复服务 (status=$code)"
log " 新版本部署失败,请检查构建/配置后重试"
return 0
else
err "回滚后服务仍未恢复 (status=$code)"
err "服务可能已完全宕机,请手动介入"
return 1
fi
}
# ---------- 打印部署摘要 ----------
print_summary() {
log "═════════════════════════════════════════════"
log "部署完成"
log " 二进制: $TARGET_BIN"
log " 配置: $TARGET_CONFIG$([[ -n "$NEW_CONFIG_SRC" ]] && echo " (本次已替换)" || echo " (未改动)")"
log " 适配器: $TARGET_ADAPTERS"
log " 插件: $TARGET_PLUGINS"
log "═════════════════════════════════════════════"
}
# ---------- 主流程 ----------
# 顺序要点:配置投放与预检都排在 restart_service **之前**,
# 于是坏配置在服务仍然健康时就被拦下,根本不会触发重启。
main() {
parse_args "$@"
log "═══ modelrouter 原子部署开始 ═══"
precheck
# 密钥校验放在 build/替换之前:它的失败后果最重(配置里的密文永久解不开),
# 必须在动任何文件之前就挡住。此阶段用的是**当前线上二进制**,它已经
# 支持 -show-secrets(首次引入该子命令的版本除外,此时跳过并提示)。
verify_master_key
build
sync_adapters
backup_old_state
atomic_replace_binary
atomic_replace_config
if ! verify_config; then
# 配置不合法:服务还没重启,只需把二进制/配置退回原状。
err "配置预检失败,回退本次改动(服务未受影响)"
[[ -f "$BACKUP_BIN" ]] && cp -f "$BACKUP_BIN" "$TARGET_BIN" && chmod 0755 "$TARGET_BIN"
if [[ -n "$NEW_CONFIG_SRC" && -f "$BACKUP_CONFIG" ]]; then
cp -f "$BACKUP_CONFIG" "$TARGET_CONFIG" && chmod 0644 "$TARGET_CONFIG"
fi
exit 1
fi
restart_service
if ! healthcheck; then
rollback || true
exit 1
fi
print_summary
}
main "$@"