mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
## 1. ★ zcode 身份块适配器(channels-zcode)—— 准入的唯一开关
参考实测(三账号 17 次请求):zcode 的 /zcode-plan/anthropic 通道做**请求体
内容检查**,system 缺官方身份块结构时直接 HTTP 405 + {"code":3012}:
无 system → 3012 ✗
仅 cliPrefix(42 字符) → 3012 ✗
cliPrefix + stable(约 2898 字符)→ 200 ✓
⇒ 判据是「身份块结构是否存在」,与 HTTP 头、版本号、频率、多轮历史、tools
声明、运行时全部无关。我们此前用通用 anthropic 适配器 ⇒ **即使拿到 zcode
账号也必然被拒**(现在 0 账号,但这是能不能用起来的前提)。
实现:插件自装 channels-zcode —— 读内核 anthropic.lua 执行取基座,**只覆盖
transform_request** 注入身份块(协议转换始终与 anthropic 那一份一致,不漂移):
block[1] cliPrefix(42) / block[2] stable(2852) / block[3] Environment 段
/ block[4] 调用方 system(**必须追加在最后**:上游看的是前缀结构)
只给最后一块打 cache_control(Anthropic 上限 4 断点,逐块打会占满预算)
日期块插入首条 user 的 content **数组**最前(拼进字符串会改变结构)
文本与参考**逐字节一致**(已核对:42 + 2856 = 2898)。
★ 基座读不到时**响亮失败**,不静默退化 —— 否则会产出"身份块看着对、工具/图像
全没转换"的请求,上游可能 200 但模型拿不到工具定义,症状是"模型变笨了",
与准入毫不相干、排查完全跑偏。
判据 4 条 + 变异 2/2:不注入 → 报红;截断到 100 字符 → 报红。
另加"协议转换不退化"判据(工具 input_schema / 图像块存活)。
## 2. ★★ 同一缺陷家族第 3 个实例:Close() 不停调度器
修完 on_unload 后全量回归 internal/lua **600s 超时**,goroutine 快照显示
**69 个卡在 stateSaver.loop 与 PluginRuntime.loop**。
根因:Runtime.Stop() **只在单插件 Unload 里调**,Close() 不调 ⇒ 每个插件的
调度循环在"进程关闭/测试结束"路径上永不停止。与 on_unload 完全同一形状。
修法:Close() 新增 stopSchedulersAll(),顺序 = 停调度 → 落盘 → on_unload
(调度器必须停在落盘之前,否则它写一个已收尾的 state)。
★ 顺带:Stop() 不幂等(裸 close 会 panic: close of closed channel),
两处都调之后可达 —— 用 sync.Once 加固。给函数加第二个调用点前先查幂等性。
实测 internal/lua 从 600s 超时 → 62s 通过。
## 3. codearts 模型目录:补两个**必须 AK/SK 签名**的端点
codearts 有 10 个账号但目录里只有硬编码 1 个模型。参考打两个端点:
① opengw.../gateway/config → result.models(benefit)
② snap-access.../v1/model/builtin → builtinModels(常规)
而 refreshRemoteModels **只支持 Bearer** ⇒ 这两端点从来没被打过。
已加 ep.sign="codearts" 分支 + 两个解析形态 + opengw host 白名单。
判据:两端点各自真实命中 + 每请求带 SDK-HMAC-SHA256 头 + **合并**(非取其一)。
## 4. TRAE PKCE 候选来源补齐(3 → 5)
参考 authCodeCandidates 有 5 个来源,我们只有 3(漏了 authCodeInfo 为 JSON 的
authCode 字段、以及**纯字符串**形态)。★ 且不能写成 `a or b or c` 链 ——
jsonField 取不到时返回**空串**,Lua 里空串是真值,链会在第一个空串处短路。
判据 5 个参数化用例 + 变异 2/2 咬住(正是 or 链取不到的两个形态)。
## 5. ★ 部署脚本:备份裁剪只修了一半("两处只修一处"再现)
prune_adapter_backups 有完整实现(含数量硬上限),但**只针对 adapters.bak**;
sync_plugins 同样每次建 plugins.bak.* —— 修了 adapters 的堆积、plugins 继续涨。
实测线上 plugins.bak **36 个**、/etc/llmsproxy 192M。
泛化为 prune_backups <prefix> + prune_all_backups(两个前缀都裁),加空前缀守卫。
判据不止逐字检查,而是**扫描脚本里所有 .bak.$(date) 创建点**,要求每个前缀都在
裁剪范围内 —— 将来新增第三种备份目录会立刻报红,而不是等它涨到几百兆。
变异 2/2 咬住(只裁 adapters / 去掉守卫)。
## 6. 上游客观缺口钉成可执行判据(TestUpstreamGapsAreDocumented)
qoder 加密端点需 .wasm(**参考仓库里也不存在**)、TRAE PKCE 参考**明确不支持**、
zcode 滑块需真实浏览器交互。写成判据:任何人(包括未来的我)想"补"这些能力,
必须先让判据失败 —— 即先推翻事实再动手。防的正是我前两轮的反复误判。
清理:channels-buddy.lua(0 字节历史遗留,每次启动报 must return a table)。
624 lines
26 KiB
Bash
Executable File
624 lines
26 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# ============================================================
|
||
# llmsproxy 原子化滚动部署脚本
|
||
#
|
||
# 策略:
|
||
# 1. /tmp (tmpfs) 构建,/usr/local/bin (ext4) 部署
|
||
# 2. 构建后先 copy 到目标文件系统(.llmsproxy.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/llmsproxy"
|
||
readonly TARGET_CONFIG="/etc/llmsproxy/config.yaml"
|
||
readonly TARGET_ADAPTERS="/etc/llmsproxy/adapters"
|
||
readonly TARGET_PLUGINS="/etc/llmsproxy/plugins"
|
||
readonly REPO_DIR="/home/program/llmsproxy"
|
||
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
|
||
readonly BIN_NAME="llmsproxy"
|
||
|
||
# 构建目录。默认落在仓库下的构建工作区,而不是 /tmp:本机 /tmp 是 8.8G
|
||
# tmpfs,实测被 Go 构建缓存占满后会让 go test/build 在链接阶段直接失败。
|
||
# 可用 LLMSPROXY_BUILD_DIR 覆盖;同一目录也被测试用作 GOTMPDIR。
|
||
readonly BUILD_SUBDIR=".build-work"
|
||
if [[ -n "${LLMSPROXY_BUILD_DIR:-}" ]]; then
|
||
readonly TMP_DIR="${LLMSPROXY_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/llmsproxy/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 权限"
|
||
|
||
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/llmsproxy
|
||
|
||
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(或
|
||
# LLMS_PROXY_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"
|
||
|
||
if [[ -n "${LLMS_PROXY_MASTER_KEY:-}" ]]; then
|
||
log " 钥匙来源: LLMS_PROXY_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 "凭据无法用 LLMS_PROXY_MASTER_KEY 解封,未替换任何文件:"
|
||
printf '%s\n' "$out" >&2
|
||
return 1
|
||
fi
|
||
|
||
if [[ ! -f "$key_file" ]]; then
|
||
err "配置含 $sealed 处密文,但钥匙文件不存在: $key_file"
|
||
err " 没有它,部署后服务将无法启动。恢复 master.key 或设置 LLMS_PROXY_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/llmsproxy 因此涨到 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/llmsproxy"
|
||
[[ -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/llmsproxy/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 opencode github groq kimicode mistral sensenova agentrouter)
|
||
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 kimicode mistral opencode; do
|
||
grep -qE '\buses\b' "$TARGET_ADAPTERS/$f.lua" 2>/dev/null && ((usage_fixed++)) || true
|
||
done
|
||
log " usage 透传修复适配器: ${usage_fixed}/7 (openai/deepseek/github/groq/kimicode/mistral/opencode)"
|
||
log "OK 适配器已同步到 $TARGET_ADAPTERS/"
|
||
}
|
||
|
||
# 同步内置插件到 plugin_dir。
|
||
#
|
||
# 内置插件是**内核发行物的一部分**(go:embed 进二进制,随版本演进),与内置适配器
|
||
# 同性质。因此这里的纪律与 sync_adapters 完全一致:**覆盖内置同名文件**(带备份),
|
||
# 保留目录里运行时新增/运维自加的 .lua。
|
||
#
|
||
# 早期实现是「只补缺,已存在一律不动」,看着安全,实际造成一个功能性缺口:
|
||
# 内置插件本身的**修复无法下发**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样
|
||
# 全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版,
|
||
# 生产行为毫无变化。内置源码的正确性由仓库保证,部署必须与二进制内容对齐。
|
||
#
|
||
# 「只补缺」原本想保护的是「运维改过 / 运行时新增的插件」,但那些都是**非内置
|
||
# 名字**:内置名一律以发行物为准,其余名字一律不动。两者不是一回事。
|
||
sync_plugins() {
|
||
log "同步内置插件"
|
||
|
||
SRC_PLUGINS="$REPO_DIR/internal/lua/plugins"
|
||
[[ -d "$SRC_PLUGINS" ]] || fail "内嵌插件目录不存在: $SRC_PLUGINS"
|
||
|
||
mkdir -p "$TARGET_PLUGINS"
|
||
|
||
# 备份将被覆盖/新增的内置插件(非内置名字不进入循环,天然不动)
|
||
BACKUP_DIR="/etc/llmsproxy/plugins.bak.$(date +%Y%m%d%H%M%S)"
|
||
mkdir -p "$BACKUP_DIR"
|
||
local added=0 updated=0 same=0
|
||
for src in "$SRC_PLUGINS"/*.lua; do
|
||
[[ -f "$src" ]] || continue
|
||
local name; name="$(basename "$src")"
|
||
if [[ -f "$TARGET_PLUGINS/$name" ]]; then
|
||
if cmp -s "$src" "$TARGET_PLUGINS/$name"; then
|
||
((same++)) || true
|
||
continue
|
||
fi
|
||
cp -f "$TARGET_PLUGINS/$name" "$BACKUP_DIR/$name.bak" 2>/dev/null || true
|
||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
||
chmod 0644 "$TARGET_PLUGINS/$name"
|
||
log " ~ 更新内置插件: $name"
|
||
((updated++)) || true
|
||
else
|
||
cp -f "$src" "$TARGET_PLUGINS/$name"
|
||
chmod 0644 "$TARGET_PLUGINS/$name"
|
||
log " + 新增内置插件: $name"
|
||
((added++)) || true
|
||
fi
|
||
done
|
||
# 没有任何改动时不必留空备份目录
|
||
if (( updated == 0 && added == 0 )); then
|
||
rmdir "$BACKUP_DIR" 2>/dev/null || true
|
||
else
|
||
log " 旧插件已备份到 $BACKUP_DIR"
|
||
fi
|
||
|
||
# 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在
|
||
for src in "$SRC_PLUGINS"/*.lua; do
|
||
[[ -f "$src" ]] || continue
|
||
local name; name="$(basename "$src")"
|
||
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
|
||
done
|
||
log "OK 插件已同步到 $TARGET_PLUGINS/(新增 $added,更新 $updated,未变 $same)"
|
||
}
|
||
|
||
# ---------- 重启服务 ----------
|
||
restart_service() {
|
||
log "重启服务(中断 <500ms)"
|
||
systemctl restart llmsproxy.service || fail "systemd restart 失败"
|
||
log "OK llmsproxy.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 llmsproxy.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 llmsproxy.service && journalctl -u llmsproxy -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 llmsproxy.service || {
|
||
err "重启失败,服务可能完全宕机"
|
||
err "请手动检查: systemctl status llmsproxy.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 "═══ llmsproxy 原子部署开始 ═══"
|
||
precheck
|
||
# 密钥校验放在 build/替换之前:它的失败后果最重(配置里的密文永久解不开),
|
||
# 必须在动任何文件之前就挡住。此阶段用的是**当前线上二进制**,它已经
|
||
# 支持 -show-secrets(首次引入该子命令的版本除外,此时跳过并提示)。
|
||
verify_master_key
|
||
build
|
||
sync_adapters
|
||
sync_plugins
|
||
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 "$@" |