mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
此前两类冲突各有问题:
A) 两个插件声明同一条 /api/plugins/http/ 服务路径 → 分发器按加载顺序静默
选一个(装个新插件就可能改变既有路径归属)。
B) 插件装与内置/他插件同名的适配器 → 硬错误 ErrAdapterNameReserved / owned by。
用户要求:都改成「报冲突、列出候选、运维选一个保留、选择持久化」,绝不崩溃
或拒绝启动。
## A 类:插件 HTTP 服务路径冲突
- dispatch(plugin_http_routes.go)同路径多认领时不再 load-order 选边:未裁决
返回 409 并点名候选;已裁决按赢家分发。
- route_conflicts.go:RouteConflicts/RouteWinner/ResolveRouteConflict,只统计
loaded 且未禁用的插件;赢家插件若不再声明该路径,裁决作废回未裁决态。
## B 类:适配器名冲突
- InstallAdapter 撞名(内置或他插件所有)不再硬拒,改挂起:现任继续服务,候选
入 AdapterConflicts,返回 typed ErrAdapterConflictPending(区分「代码错」)。
- ResolveAdapterConflict:incumbent 保留现任 / candidate 真正装上插件版(绕过
reserved 门禁——这是运维显式授权覆盖内置的唯一入口)并转移归属。
- 现任内置永不被静默覆盖,守住「第三方插件不得篡改内核发行物」信任边界。
## 持久化 + WebAPI + WebUI
- 两张裁决表落 .route-conflicts.json(routes + adapters),LoadDir 时读回;
重启后插件再装同名适配器自动套用上次裁决。
- WebAPI:GET /api/plugins/conflicts 列冲突;POST {kind,path|name,winner} 裁决。
- WebUI:插件页顶部「冲突裁决」卡片,逐条列路径/适配器冲突 + 候选 + 裁决按钮。
## 测试
- 单测 conflict_arbitration_test.go 6 项全过:路径冲突检测/裁决/持久化/重启、
赢家消失回退、适配器撞名挂起/incumbent/candidate/归属转移、非法 winner 拒绝、
自身更新不受影响(token 轮换不走冲突路径)。
- e2e conflict_arbitration_e2e_test.go 2 项全过(真二进制):两插件抢路径→409
点名→裁决→200 赢家应答→/v1/models 无回归;插件装 shadow openai→挂起→现任
保留→裁决 candidate→插件版生效→冲突清空。
- 全量 build + vet 通过,既有 lua/gateway/core 回归无破坏。
修正过程中发现并修了一个真 bug:LoadAdapterSource 对 key≠declared 的适配器
重装会建第二个池、留旧池继续服务(v2 装了 v1 还在跑)——改成统一按注册键
归档,裁决 candidate 后新版本才真正生效。
288 lines
10 KiB
Go
288 lines
10 KiB
Go
package lua
|
||
|
||
import (
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// TestRouteConflictDetectedAndArbitrated(用户要求 A):
|
||
// 两个插件声明同一条 /api/plugins/http/ 服务路径 → 必须报冲突(列出双方),
|
||
// 运维裁决后裁决生效;裁决持久化,重启(重新 LoadDir)后仍然生效。
|
||
// 全程不崩溃、不拒绝加载 —— 两个插件都保持 loaded。
|
||
func TestRouteConflictDetectedAndArbitrated(t *testing.T) {
|
||
dir := t.TempDir()
|
||
_, ps := bridgeVM(t)
|
||
ps.dir = dir
|
||
|
||
// a.lua 与 b.lua 都声明路径 "shared"
|
||
const aSrc = `
|
||
local P = {}
|
||
P.name = "plugA"
|
||
P.version = "1"
|
||
P.exports = {}
|
||
P.http = { { path = "shared", handler = "h", roles = {"admin"} } }
|
||
function P.h(req) return { status = 200, json = { from = "A" } } end
|
||
return P
|
||
`
|
||
const bSrc = `
|
||
local P = {}
|
||
P.name = "plugB"
|
||
P.version = "1"
|
||
P.exports = {}
|
||
P.http = { { path = "shared", handler = "h", roles = {"admin"} } }
|
||
function P.h(req) return { status = 200, json = { from = "B" } } end
|
||
return P
|
||
`
|
||
for name, src := range map[string]string{"plugA": aSrc, "plugB": bSrc} {
|
||
if err := ps.LoadSource(name, src); err != nil {
|
||
t.Fatalf("加载 %s: %v", name, err)
|
||
}
|
||
}
|
||
|
||
// ① 冲突被报出来,候选齐整,未裁决
|
||
cs := ps.RouteConflicts()
|
||
if len(cs) != 1 {
|
||
t.Fatalf("应恰有 1 条路径冲突, 实得 %d: %+v", len(cs), cs)
|
||
}
|
||
c := cs[0]
|
||
if c.Path != "shared" {
|
||
t.Fatalf("冲突路径应为 shared, 实得 %q", c.Path)
|
||
}
|
||
if len(c.Candidates) != 2 || c.Candidates[0] != "plugA" || c.Candidates[1] != "plugB" {
|
||
t.Fatalf("候选应为 [plugA plugB], 实得 %v", c.Candidates)
|
||
}
|
||
if c.Resolved != "" {
|
||
t.Fatalf("初始应未裁决, 实得 %q", c.Resolved)
|
||
}
|
||
// 两个插件都保持 loaded(不许因冲突拒绝启动/加载)
|
||
if ps.List()[0]["loaded"] != true || ps.List()[1]["loaded"] != true {
|
||
t.Fatal("冲突的两个插件都应保持 loaded")
|
||
}
|
||
|
||
// ② 非法裁决被拒(赢家必须确实是候选之一)
|
||
if err := ps.ResolveRouteConflict("shared", "plugC"); err == nil {
|
||
t.Fatal("赢家不是候选应被拒绝")
|
||
}
|
||
if err := ps.ResolveRouteConflict("other", "plugA"); err == nil {
|
||
t.Fatal("未冲突的路径不应可裁决")
|
||
}
|
||
|
||
// ③ 合法裁决 + 持久化
|
||
if err := ps.ResolveRouteConflict("shared", "plugB"); err != nil {
|
||
t.Fatalf("合法裁决失败: %v", err)
|
||
}
|
||
if ps.RouteWinner("shared") != "plugB" {
|
||
t.Fatalf("裁决后赢家应为 plugB, 实得 %q", ps.RouteWinner("shared"))
|
||
}
|
||
if _, err := os.Stat(filepath.Join(dir, ".route-conflicts.json")); err != nil {
|
||
t.Fatalf("裁决应落盘 .route-conflicts.json: %v", err)
|
||
}
|
||
|
||
// ④ 重启语义:新 registry 读回裁决
|
||
_, ps2 := bridgeVM(t)
|
||
ps2.dir = dir
|
||
if err := ps2.LoadRouteConflicts(); err != nil {
|
||
t.Fatalf("读回裁决: %v", err)
|
||
}
|
||
if err := ps2.LoadSource("plugA", aSrc); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps2.LoadSource("plugB", bSrc); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
cs2 := ps2.RouteConflicts()
|
||
if len(cs2) != 1 || cs2[0].Resolved != "plugB" {
|
||
t.Fatalf("重启后裁决应保留 plugB, 实得 %+v", cs2)
|
||
}
|
||
}
|
||
|
||
// TestRouteConflictWinnerGoneBackToUnresolved:已裁决的赢家插件被删/改后不再
|
||
// 声明该路径 → 裁决作废回到未裁决态,而不是指向一个不存在的赢家。
|
||
func TestRouteConflictWinnerGoneBackToUnresolved(t *testing.T) {
|
||
_, ps := bridgeVM(t)
|
||
const aSrc = `
|
||
local P = {}
|
||
P.name = "plugA"
|
||
P.http = { { path = "s", handler = "h", roles = {"admin"} } }
|
||
function P.h(req) return { status = 200, json = {} } end
|
||
return P
|
||
`
|
||
const bSrc = `
|
||
local P = {}
|
||
P.name = "plugB"
|
||
P.http = { { path = "s", handler = "h", roles = {"admin"} } }
|
||
function P.h(req) return { status = 200, json = {} } end
|
||
return P
|
||
`
|
||
if err := ps.LoadSource("plugA", aSrc); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.LoadSource("plugB", bSrc); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.ResolveRouteConflict("s", "plugA"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// plugA 被禁用 → 不再占路由 → 冲突消失(不是回未裁决)
|
||
if err := ps.SetEnabled("plugA", false); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := ps.RouteConflicts(); len(got) != 0 {
|
||
t.Fatalf("只 plugB 声明时不应再有冲突, 实得 %+v", got)
|
||
}
|
||
}
|
||
|
||
// TestAdapterNameConflictSuspendsForArbitration(用户要求 B):
|
||
// 插件安装与内置同名的适配器 → 不再硬拒绝,而是挂起待裁决:
|
||
// - 现任内置继续服务(版本号不变)
|
||
// - 返回 ErrAdapterConflictPending(typed,可区分「代码错」)
|
||
// - 冲突出现在 AdapterConflicts() 列表
|
||
// - 运维 ResolveAdapterConflict("incumbent") → 现任保留、冲突清除
|
||
// - 运维 ResolveAdapterConflict("candidate") → 插件版真正安装、归属转移
|
||
// - 裁决持久化;重启后插件再装同名适配器自动套用上次裁决
|
||
func TestAdapterNameConflictSuspendsForArbitration(t *testing.T) {
|
||
dir := t.TempDir()
|
||
_, ps := bridgeVM(t)
|
||
ps.dir = dir
|
||
|
||
builtin := `local a={} a.name="openai" a.version="BUILTIN" a.endpoint="/x" return a`
|
||
if err := ps.vm.LoadAdapterSource("openai", builtin); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
evil := `local a={} a.name="openai" a.version="PLUGIN_V1" a.endpoint="/x" return a`
|
||
|
||
// ① 安装挂起:现任保留,typed 错误,不静默覆盖
|
||
got, err := ps.InstallAdapter("sneaky", "openai", evil)
|
||
if err == nil {
|
||
t.Fatal("撞内置名应返回错误(挂起)")
|
||
}
|
||
var pend *ErrAdapterConflictPending
|
||
if !errors.As(err, &pend) {
|
||
t.Fatalf("错误类型应为 ErrAdapterConflictPending, got %T: %v", err, err)
|
||
}
|
||
if got != "" {
|
||
t.Fatalf("挂起时不得返回生效名, 实得 %q", got)
|
||
}
|
||
if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" {
|
||
t.Fatalf("现任内置必须继续服务, 版本实得 %q", v)
|
||
}
|
||
|
||
// ② 冲突可见
|
||
ac := ps.AdapterConflicts()
|
||
if len(ac) != 1 || ac[0].Name != "openai" || ac[0].Candidate != "sneaky" {
|
||
t.Fatalf("挂起冲突应可见: %+v", ac)
|
||
}
|
||
|
||
// ③ 裁决 incumbent → 现任保留
|
||
if err := ps.ResolveAdapterConflict("openai", "incumbent"); err != nil {
|
||
t.Fatalf("裁决 incumbent: %v", err)
|
||
}
|
||
if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" {
|
||
t.Fatalf("裁决 incumbent 后内置应保留, 实得 %q", v)
|
||
}
|
||
if got := ps.AdapterConflicts(); len(got) != 0 {
|
||
t.Fatalf("裁决后冲突应清除, 实得 %+v", got)
|
||
}
|
||
// 归属仍是 operator(内置)
|
||
if o := ps.AdapterOwnerOf("openai"); o != "" {
|
||
t.Fatalf("内置归属应为空(operator), 实得 %q", o)
|
||
}
|
||
|
||
// ④ 重启后插件再装 → 之前的 incumbent 裁决自动套用(不再二次挂起,也不安装)
|
||
if err := ps.LoadRouteConflicts(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, err = ps.InstallAdapter("sneaky", "openai", evil)
|
||
var pend2 *ErrAdapterConflictPending
|
||
if !errors.As(err, &pend2) {
|
||
t.Fatalf("自动套用 incumbent 裁决应仍返回 pending 错误(未安装), got %v", err)
|
||
}
|
||
if v := ps.vm.AdapterVersion("openai"); v != "BUILTIN" {
|
||
t.Fatalf("持久 incumbent 裁决应保持内置, 实得 %q", v)
|
||
}
|
||
|
||
// ⑤ 裁决 candidate → 插件版真正装上、归属转移、持久化
|
||
if err := ps.ResolveAdapterConflict("openai", "candidate"); err != nil {
|
||
t.Fatalf("裁决 candidate: %v", err)
|
||
}
|
||
if v := ps.vm.AdapterVersion("openai"); v != "PLUGIN_V1" {
|
||
t.Fatalf("裁决 candidate 后插件版应生效, 实得 %q", v)
|
||
}
|
||
if o := ps.AdapterOwnerOf("openai"); o != "sneaky" {
|
||
t.Fatalf("归属应转移给 sneaky, 实得 %q", o)
|
||
}
|
||
// 持久化文件里是 candidate:sneaky
|
||
b, _ := os.ReadFile(filepath.Join(dir, ".route-conflicts.json"))
|
||
if !strings.Contains(string(b), "candidate:sneaky") {
|
||
t.Fatalf("裁决应持久化为 candidate:sneaky, 文件: %s", b)
|
||
}
|
||
}
|
||
|
||
// TestAdapterPluginConflictSuspendsForArbitration:插件 A 装了适配器后插件 B
|
||
// 再装同名 → 挂起待裁决(与内置撞名同一机制),现任 A 的版本继续服务。
|
||
func TestAdapterPluginConflictSuspendsForArbitration(t *testing.T) {
|
||
_, ps := bridgeVM(t)
|
||
if _, err := ps.InstallAdapter("alpha", "shared_auth", tokenAdapterV1); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
_, err := ps.InstallAdapter("beta", "shared_auth", tokenAdapterV2)
|
||
var pend *ErrAdapterConflictPending
|
||
if !errors.As(err, &pend) {
|
||
t.Fatalf("B 装同名应挂起, got %v", err)
|
||
}
|
||
// tokenAdapterV1 声明 name="myauth",池落在生效名下(注册键 shared_auth)。
|
||
if v := ps.vm.AdapterVersion("myauth"); v != "1.0" {
|
||
t.Fatalf("alpha 的现任版本应继续服务, 实得 %q", v)
|
||
}
|
||
ac := ps.AdapterConflicts()
|
||
if len(ac) != 1 || ac[0].Incumbent != "plugin:alpha" {
|
||
t.Fatalf("冲突应点名现任归属 plugin:alpha, 实得 %+v", ac)
|
||
}
|
||
// 裁决 candidate → beta 接管
|
||
if err := ps.ResolveAdapterConflict("shared_auth", "candidate"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if v := ps.vm.AdapterVersion("myauth"); v != "2.0" {
|
||
t.Fatalf("beta 版应生效, 实得 %q", v)
|
||
}
|
||
if o := ps.AdapterOwnerOf("myauth"); o != "beta" {
|
||
t.Fatalf("归属应转移给 beta, 实得 %q", o)
|
||
}
|
||
}
|
||
|
||
// TestAdapterConflictRejectsBadWinner:裁决值必须合法;无挂起冲突时拒绝。
|
||
func TestAdapterConflictRejectsBadWinner(t *testing.T) {
|
||
_, ps := bridgeVM(t)
|
||
if err := ps.ResolveAdapterConflict("nope", "candidate"); err == nil {
|
||
t.Fatal("无挂起冲突应拒绝")
|
||
}
|
||
if _, err := ps.InstallAdapter("p", "myauth", tokenAdapterV1); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// myauth 是插件自己装的 → 更新路径(update 走 UpdateAdapter,非冲突)
|
||
if err := ps.ResolveAdapterConflict("myauth", "bogus"); err == nil {
|
||
t.Fatal("非法 winner 值应拒绝")
|
||
}
|
||
}
|
||
|
||
// TestPluginOwnUpdateStillWorks:插件更新自己装的适配器(token 轮换)不走冲突
|
||
// 路径 —— 行为与改造前一致。守住「挂起只发生在撞名」这条边界。
|
||
func TestPluginOwnUpdateStillWorks(t *testing.T) {
|
||
_, ps := bridgeVM(t)
|
||
if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ps.UpdateAdapter("authtoken", "myauth", tokenAdapterV2); err != nil {
|
||
t.Fatalf("own update 不应受影响: %v", err)
|
||
}
|
||
if v := ps.vm.AdapterVersion("myauth"); v != "2.0" {
|
||
t.Fatalf("轮换后版本应 2.0, 实得 %q", v)
|
||
}
|
||
if got := ps.AdapterConflicts(); len(got) != 0 {
|
||
t.Fatalf("own update 不应产生冲突, 实得 %+v", got)
|
||
}
|
||
}
|