Files
ModelRouter/internal/lua/conflict_arbitration_test.go
JianFeeeee 5948ad19ca feat(plugins): 服务路径/适配器名冲突进裁决而非崩溃或硬拒绝(用户要求 A+B)
此前两类冲突各有问题:
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 后新版本才真正生效。
2026-10-12 12:32:10 +08:00

288 lines
10 KiB
Go
Raw 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.

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)
}
}