From 84a9fccd9a784b5fc73c1231a5c7c4ea24b07154 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 12 Oct 2026 19:07:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugins):=20=E6=96=B0=E5=86=85=E7=BD=AE?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E8=A6=81=E8=83=BD=E8=90=BD=E5=9C=B0=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20SeedBundled=20=E6=94=B9=E4=B8=BA=E6=8C=89?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=A1=A5=E9=BD=90=EF=BC=8Cdeploy.sh=20?= =?UTF-8?q?=E8=A1=A5=E6=8F=92=E4=BB=B6=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实事故:channels.lua(多平台账号接入插件)在仓库里、在 2.0 二进制里 (go:embed),生产却完全看不到它 —— /api/plugins 只有 billing/quota/usage。 根因是两处叠加: 1. SeedBundled 只在 plugin_dir **不存在**时才写内置插件。生产 plugin_dir 从 首次安装起就存在,于是后来加入二进制的新插件永远不会落盘。目录存在不等于 内容是最新的 —— 这个假设把"新增内置插件"整条路径堵死了。 改为**按文件补齐**:只在该文件缺失时才 seed,已存在的一律不动(运维自己改 过、或运行时新增的插件必须跨升级存活 —— 与适配器同一条规则)。 2. deploy.sh 完全不处理 plugins(只同步 adapters/)。即使内核修好了,部署脚本 也没有把插件内容与仓库对齐的动作。新增 sync_plugins:备份、只补缺、对已有 文件零改动、末尾逐个校验内嵌插件都在目标目录,摘要里打印插件目录。 测试(internal/lua/vm_test.go): - TestSeedBundledFillsMissingFilesInExistingDir:构造确切事故条件(目录已存在、 只有部分插件),断言缺失的被补齐、运维自己的 billing.lua 不被覆盖、补齐后 channels 确实出现在插件表里。 - TestSeedBundledIdempotent:二次 seed 不覆盖已就位文件。 deploy.sh 通过 bash -n;go test ./internal/lua/ 全绿。 --- deploy.sh | 49 +++++++++++++++++++++ internal/lua/plugins.go | 18 +++++--- internal/lua/vm.go | 12 +++++- internal/lua/vm_test.go | 94 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 6 deletions(-) diff --git a/deploy.sh b/deploy.sh index 27712fe..bd5fbd3 100755 --- a/deploy.sh +++ b/deploy.sh @@ -31,6 +31,7 @@ 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" @@ -390,6 +391,52 @@ sync_adapters() { log "✓ 适配器已同步到 $TARGET_ADAPTERS/" } +# 同步内置插件到 plugin_dir。 +# +# 为什么需要它:新内置插件(如 channels.lua)过去只能靠内核的 SeedBundled 落地, +# 而旧版 SeedBundled "目录已存在就什么都不做",于是升级后新插件永远不会出现在 +# 生产 —— 它在二进制里、却不在 /api/plugins。内核端已修(改为按文件补齐), +# 这里再显式同步一道,让部署与二进制内容对齐、不依赖内核那一侧的行为。 +# +# 只补缺:已存在的插件文件一律不动。运维自己改过、或运行时新增的插件必须跨 +# 部署存活;覆盖它们会静默抹掉线上正在用的逻辑。 +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)" + local added=0 + mkdir -p "$BACKUP_DIR" + for src in "$SRC_PLUGINS"/*.lua; do + [[ -f "$src" ]] || continue + local name; name="$(basename "$src")" + if [[ -f "$TARGET_PLUGINS/$name" ]]; then + continue # 已存在:运维版本优先,不覆盖 + fi + cp -f "$src" "$TARGET_PLUGINS/$name" + chmod 0644 "$TARGET_PLUGINS/$name" + log " + 新增内置插件: $name" + ((added++)) || true + done + if (( added == 0 )); then + log " 无新增内置插件(均已存在)" + rmdir "$BACKUP_DIR" 2>/dev/null || true + 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 "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,已有文件未改动)" +} + # ---------- 重启服务 ---------- restart_service() { log "重启服务(中断 <500ms)" @@ -495,6 +542,7 @@ print_summary() { log " 二进制: $TARGET_BIN" log " 配置: $TARGET_CONFIG$([[ -n "$NEW_CONFIG_SRC" ]] && echo " (本次已替换)" || echo " (未改动)")" log " 适配器: $TARGET_ADAPTERS" + log " 插件: $TARGET_PLUGINS" log "═════════════════════════════════════════════" } @@ -511,6 +559,7 @@ main() { verify_master_key build sync_adapters + sync_plugins backup_old_state atomic_replace_binary atomic_replace_config diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 45126cc..2ce5e68 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -641,15 +641,23 @@ func (ps *Plugins) LoadDir() error { // directory does not exist yet, mirroring the adapter seeding rule: once the // directory exists it is authoritative, so deleting or editing a shipped plugin // is a real action that survives restarts. +// SeedBundled writes the plugins shipped with the gateway into dir when they are +// MISSING. +// +// This used to be "write everything only when the directory does not exist", +// which silently stopped delivering plugins added to the binary LATER: the +// directory had existed since the first install, so a newly embedded plugin +// (e.g. channels.lua) never landed on an upgraded deployment — it was in the +// binary and completely invisible in /api/plugins. The directory's mere +// existence is not evidence that its contents are current. +// +// The rule is now per-file: seed a bundled plugin only when a file of that name +// is absent. An OPERATOR'S file always wins, so customising or replacing a +// bundled plugin survives an upgrade — the same rule adapters already use. func (ps *Plugins) SeedBundled() error { if ps.dir == "" { return nil } - if _, err := os.Stat(ps.dir); err == nil { - return nil - } else if !os.IsNotExist(err) { - return err - } return writeBundledPlugins(ps.dir) } diff --git a/internal/lua/vm.go b/internal/lua/vm.go index 1b43ec7..f8ca65c 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -744,11 +744,21 @@ func writeBundledPlugins(dir string) error { if e.IsDir() || filepath.Ext(e.Name()) != ".lua" { continue } + dst := filepath.Join(dir, e.Name()) + // Per-file seeding: a plugin already present is left ALONE. That is what + // makes an operator's own copy (or a locally edited one) survive an + // upgrade, and it is also why a newly embedded plugin still arrives — + // absence of THAT file, not of the directory, is the trigger. + if _, err := os.Stat(dst); err == nil { + continue + } else if !os.IsNotExist(err) { + return err + } data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + e.Name()) if err != nil { continue } - if err := os.WriteFile(filepath.Join(dir, e.Name()), data, 0644); err != nil { + if err := os.WriteFile(dst, data, 0644); err != nil { return err } } diff --git a/internal/lua/vm_test.go b/internal/lua/vm_test.go index ca8d8b9..e3c4702 100644 --- a/internal/lua/vm_test.go +++ b/internal/lua/vm_test.go @@ -1868,3 +1868,97 @@ func TestAnthropicUsageCacheMapping(t *testing.T) { t.Errorf("usage-less chunk must not emit a usage object: %s", out) } } + +// TestSeedBundledFillsMissingFilesInExistingDir 是真实事故的回归测试。 +// +// 事故:SeedBundled 原本"目录已存在就什么都不做"。生产 plugin_dir 从首次安装 +// 起就存在,于是后来加入二进制的新内置插件(channels.lua)永远不会落盘 —— +// 它在 go:embed 里、却完全不出现在 /api/plugins(线上只加载 billing/quota/usage)。 +// +// 本测试构造那个确切条件:目录已存在且只装了部分内置插件,断言缺失的被补齐、 +// 已存在的(模拟运维自定义)**不被覆盖**。 +func TestSeedBundledFillsMissingFilesInExistingDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "plugins") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + // 模拟:目录已存在,里面只有运维自己的一份 billing.lua(内容故意不同) + const customBilling = "-- operator's own billing plugin\nreturn { name='billing', version='9.9.9' }" + if err := os.WriteFile(filepath.Join(dir, "billing.lua"), []byte(customBilling), 0o644); err != nil { + t.Fatal(err) + } + + vm := NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + ps := NewPlugins(vm, dir) + + if err := ps.SeedBundled(); err != nil { + t.Fatalf("seed: %v", err) + } + + // ① 已存在的运维文件不被覆盖 + got, err := os.ReadFile(filepath.Join(dir, "billing.lua")) + if err != nil { + t.Fatal(err) + } + if string(got) != customBilling { + t.Errorf("运维自己的 billing.lua 被覆盖了 —— 升级会抹掉运维的改动") + } + + // ② 每个内置插件现在都在目录里(这正是 channels.lua 缺失的那个洞) + entries, err := bundledAdapters.ReadDir(bundledPluginsDir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".lua" { + continue + } + if _, err := os.Stat(filepath.Join(dir, e.Name())); err != nil { + t.Errorf("内置插件 %s 未被补齐(目录已存在时被跳过)", e.Name()) + } + } + + // ③ 补齐后确实能被加载(channels 必须真的出现在插件表里) + if err := ps.LoadDir(); err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, p := range ps.List() { + names[p["name"].(string)] = true + } + if !names["channels"] { + t.Errorf("channels 插件应被 seed 并加载, 实得 %v", names) + } +} + +// TestSeedBundledIdempotent 二次调用不应改变已就位的文件。 +func TestSeedBundledIdempotent(t *testing.T) { + dir := filepath.Join(t.TempDir(), "plugins") + vm := NewVM(filepath.Join(t.TempDir(), "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + ps := NewPlugins(vm, dir) + + if err := ps.SeedBundled(); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(filepath.Join(dir, "channels.lua")) + // 运维改一笔 + edited := append(before, []byte("\n-- operator edit\n")...) + if err := os.WriteFile(filepath.Join(dir, "channels.lua"), edited, 0o644); err != nil { + t.Fatal(err) + } + if err := ps.SeedBundled(); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(filepath.Join(dir, "channels.lua")) + if string(after) != string(edited) { + t.Error("二次 seed 覆盖了已存在的文件") + } +}