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 后新版本才真正生效。
334 lines
11 KiB
Go
334 lines
11 KiB
Go
// Package lua — 服务路径/适配器名冲突裁决(用户要求 A + B)
|
||
//
|
||
// 用户要求:当两个东西声明同一个「服务路径」时,内核**不许崩溃、不许拒绝
|
||
// 启动**,而是报冲突、把候选都列出来、让运维在 WebUI/WebAPI 里选一个保留,
|
||
// 选择持久化生效。两类冲突:
|
||
//
|
||
// A) 两个插件在 plugin.http 里声明同一条服务路径(如都声明 "stats")。
|
||
// 改造前分发器按加载顺序静默选一个 —— 装个新插件就可能改变既有路径归属。
|
||
// B) 一个插件安装的适配器,名字撞上内置适配器或另一个插件已装的适配器。
|
||
// 改造前这是硬错误(ErrAdapterNameReserved / owned by 另一插件)。
|
||
//
|
||
// 统一处理:
|
||
//
|
||
// - 冲突是**运维策略**,与 .access.json / .kernel-perms.json 同源,单独落
|
||
// .route-conflicts.json。放单独文件而不是塞进既有策略文件,是因为那些文件
|
||
// 形状不同(map[string]string 权限 spec),混进去会让旧二进制读到无法
|
||
// round-trip 的键。
|
||
// - 「安全默认」是**保持现任**:A 类未裁决时该路径回 409 并列出候选(不静默
|
||
// 选一个);B 类未裁决时现任适配器(内置/先装的插件)继续服务,新候选挂起
|
||
// (incumbent 永不被悄悄换掉,这保住了「第三方插件不得篡改内核发行物」这条
|
||
// 信任边界 —— 只有运维显式选了插件版本才会覆盖内置)。
|
||
package lua
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// RouteConflict 描述一条被多于一个插件认领的服务路径(A 类)。
|
||
type RouteConflict struct {
|
||
Path string `json:"path"`
|
||
Candidates []string `json:"candidates"`
|
||
Resolved string `json:"resolved"` // 运维选定的赢家插件名;"" = 未裁决(分发回 409)
|
||
}
|
||
|
||
// AdapterConflict 描述一个被挂起的适配器安装(B 类):某插件想装的适配器名
|
||
// 撞上了现任(内置或另一插件)。现任继续服务,候选挂起等运维裁决。
|
||
type AdapterConflict struct {
|
||
Name string `json:"name"`
|
||
Incumbent string `json:"incumbent"` // "operator"(含内置)或 "plugin:<名>"
|
||
Candidate string `json:"candidate"` // 想装它的插件名
|
||
Resolved string `json:"resolved"` // "incumbent" / "candidate" / "" 未裁决
|
||
candidateCode string // 候选代码,运维选 candidate 时才真正装(仅内存)
|
||
}
|
||
|
||
const routeConflictsFileName = ".route-conflicts.json"
|
||
|
||
// persistedConflicts 是 .route-conflicts.json 的磁盘形态:两张裁决表。
|
||
// routes: 归一化路径 -> 赢家插件名
|
||
// adapters: 适配器名 -> "incumbent" | "candidate:<插件名>"
|
||
type persistedConflicts struct {
|
||
Routes map[string]string `json:"routes,omitempty"`
|
||
Adapters map[string]string `json:"adapters,omitempty"`
|
||
}
|
||
|
||
func (ps *Plugins) routeConflictsFile() string {
|
||
if ps.dir == "" {
|
||
return ""
|
||
}
|
||
return ps.dir + string(os.PathSeparator) + routeConflictsFileName
|
||
}
|
||
|
||
// normalizeRoutePath 把声明路径归一化:去首尾斜杠。裁决键与分发键共用它,
|
||
// 不可能对不上。
|
||
func normalizeRoutePath(p string) string {
|
||
return strings.Trim(strings.TrimSpace(p), "/")
|
||
}
|
||
|
||
// ---- A 类:插件 HTTP 服务路径冲突 ----
|
||
|
||
// RouteConflicts 扫描已加载插件的 http 声明,返回被多于一个插件认领的路径。
|
||
// 只看 loaded 且未禁用的插件:加载失败/被关掉的插件不占路由。
|
||
func (ps *Plugins) RouteConflicts() []RouteConflict {
|
||
ps.mu.RLock()
|
||
defer ps.mu.RUnlock()
|
||
byPath := map[string]map[string]bool{}
|
||
for _, p := range ps.plugins {
|
||
if p.LoadError != "" || p.Disabled {
|
||
continue
|
||
}
|
||
for _, h := range p.http {
|
||
key := normalizeRoutePath(h.Path)
|
||
if key == "" {
|
||
continue
|
||
}
|
||
if byPath[key] == nil {
|
||
byPath[key] = map[string]bool{}
|
||
}
|
||
byPath[key][p.Info.Name] = true
|
||
}
|
||
}
|
||
var out []RouteConflict
|
||
for path, owners := range byPath {
|
||
if len(owners) < 2 {
|
||
continue
|
||
}
|
||
cands := make([]string, 0, len(owners))
|
||
for name := range owners {
|
||
cands = append(cands, name)
|
||
}
|
||
sort.Strings(cands)
|
||
resolved := ps.routeWinners[path]
|
||
if resolved != "" && !owners[resolved] {
|
||
resolved = "" // 赢家已不再声明这条路径 → 回到未裁决,而非指向不存在的赢家
|
||
}
|
||
out = append(out, RouteConflict{Path: path, Candidates: cands, Resolved: resolved})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||
return out
|
||
}
|
||
|
||
// RouteWinner 返回某条路径运维选定的赢家插件名;无冲突/未裁决时 ""。
|
||
func (ps *Plugins) RouteWinner(path string) string {
|
||
key := normalizeRoutePath(path)
|
||
ps.mu.RLock()
|
||
defer ps.mu.RUnlock()
|
||
return ps.routeWinners[key]
|
||
}
|
||
|
||
// ResolveRouteConflict 记录运维对某条冲突路径的裁决并落盘。winner 必须是当前
|
||
// 确实声明了这条路径的候选之一 —— 否则分发会永远 404,所以直接拒绝。
|
||
func (ps *Plugins) ResolveRouteConflict(path, winner string) error {
|
||
key := normalizeRoutePath(path)
|
||
if key == "" {
|
||
return &ConflictError{Reason: "empty path"}
|
||
}
|
||
var cands []string
|
||
for _, c := range ps.RouteConflicts() {
|
||
if c.Path == key {
|
||
cands = c.Candidates
|
||
break
|
||
}
|
||
}
|
||
if cands == nil {
|
||
return &ConflictError{Reason: "path " + key + " is not contested"}
|
||
}
|
||
ok := false
|
||
for _, c := range cands {
|
||
if c == winner {
|
||
ok = true
|
||
break
|
||
}
|
||
}
|
||
if !ok {
|
||
return &ConflictError{Reason: "plugin " + winner + " does not serve " + key}
|
||
}
|
||
ps.mu.Lock()
|
||
if ps.routeWinners == nil {
|
||
ps.routeWinners = map[string]string{}
|
||
}
|
||
ps.routeWinners[key] = winner
|
||
ps.saveConflictsLocked()
|
||
ps.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// ---- B 类:适配器名冲突 ----
|
||
|
||
// recordAdapterConflict 挂起一个撞名的适配器安装。调用方(InstallAdapter)在
|
||
// 检测到现任存在时调用。若运维此前已裁决「candidate 赢」,返回 shouldApply=true
|
||
// 让调用方直接安装(重启后插件再装会自动套用上次裁决,不再二次挂起)。
|
||
func (ps *Plugins) recordAdapterConflict(name, incumbent, candidate, code string) (shouldApply bool) {
|
||
ps.mu.Lock()
|
||
defer ps.mu.Unlock()
|
||
if d := ps.adapterWinners[name]; d != "" {
|
||
// 已有持久裁决。candidate:<此插件> → 直接应用。否则把它重新挂回冲突
|
||
// 列表:持久裁决是**默认**,不是终审 —— 新的安装尝试要把冲突重新
|
||
// 摆到运维面前(否则裁决过 incumbent 后冲突消失,运维想改选 candidate
|
||
// 就再也没有入口了)。
|
||
if d == "candidate:"+candidate {
|
||
return true
|
||
}
|
||
}
|
||
if ps.adapterConflicts == nil {
|
||
ps.adapterConflicts = map[string]*AdapterConflict{}
|
||
}
|
||
ps.adapterConflicts[name] = &AdapterConflict{
|
||
Name: name,
|
||
Incumbent: incumbent,
|
||
Candidate: candidate,
|
||
candidateCode: code,
|
||
}
|
||
return false
|
||
}
|
||
|
||
// AdapterConflicts 列出当前挂起的适配器撞名冲突。
|
||
func (ps *Plugins) AdapterConflicts() []AdapterConflict {
|
||
ps.mu.RLock()
|
||
defer ps.mu.RUnlock()
|
||
out := make([]AdapterConflict, 0, len(ps.adapterConflicts))
|
||
for _, c := range ps.adapterConflicts {
|
||
out = append(out, AdapterConflict{
|
||
Name: c.Name, Incumbent: c.Incumbent, Candidate: c.Candidate, Resolved: c.Resolved,
|
||
})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||
return out
|
||
}
|
||
|
||
// ResolveAdapterConflict 裁决一个挂起的适配器撞名冲突。
|
||
//
|
||
// winner == "incumbent" → 保持现任,丢弃挂起候选。
|
||
// winner == "candidate" → 真正安装候选插件的适配器(覆盖现任),记录归属。
|
||
// 这是运维显式授权一个插件覆盖内置/他人适配器的唯一入口。
|
||
//
|
||
// 裁决持久化:下次该插件再装同名适配器会自动套用(不再挂起)。
|
||
func (ps *Plugins) ResolveAdapterConflict(name, winner string) error {
|
||
ps.mu.Lock()
|
||
c := ps.adapterConflicts[name]
|
||
ps.mu.Unlock()
|
||
if c == nil {
|
||
return &ConflictError{Reason: "adapter " + name + " has no pending conflict"}
|
||
}
|
||
switch winner {
|
||
case "incumbent":
|
||
ps.mu.Lock()
|
||
if ps.adapterWinners == nil {
|
||
ps.adapterWinners = map[string]string{}
|
||
}
|
||
ps.adapterWinners[name] = "incumbent"
|
||
delete(ps.adapterConflicts, name)
|
||
ps.saveConflictsLocked()
|
||
ps.mu.Unlock()
|
||
return nil
|
||
case "candidate":
|
||
// 真正安装候选代码。绕过 reserved 门禁:这正是运维显式授权覆盖的语义。
|
||
if ps.vm == nil {
|
||
return &ConflictError{Reason: "no adapter VM available"}
|
||
}
|
||
if err := ps.vm.LoadAdapterSource(name, c.candidateCode); err != nil {
|
||
return err
|
||
}
|
||
effective := ps.vm.EffectiveAdapterName(name)
|
||
ps.adapterOwner().set(effective, c.Candidate)
|
||
if effective != name {
|
||
ps.adapterOwner().set(name, c.Candidate)
|
||
}
|
||
ps.mu.Lock()
|
||
if ps.adapterWinners == nil {
|
||
ps.adapterWinners = map[string]string{}
|
||
}
|
||
ps.adapterWinners[name] = "candidate:" + c.Candidate
|
||
delete(ps.adapterConflicts, name)
|
||
ps.saveConflictsLocked()
|
||
ps.mu.Unlock()
|
||
return nil
|
||
default:
|
||
return &ConflictError{Reason: "winner must be \"incumbent\" or \"candidate\""}
|
||
}
|
||
}
|
||
|
||
// ConflictError 是裁决动作被拒的原因,点名性质(用户要求「避免静默错误」)。
|
||
type ConflictError struct{ Reason string }
|
||
|
||
func (e *ConflictError) Error() string { return "conflict: " + e.Reason }
|
||
|
||
// ErrAdapterConflictPending 是 InstallAdapter 在撞名时返回的 typed 错误:
|
||
// 安装没有成功,但也不是硬失败 —— 它被挂起等运维裁决。插件/UI 据此区分
|
||
// 「代码写错」与「撞名待裁决」。
|
||
type ErrAdapterConflictPending struct {
|
||
Name string
|
||
Incumbent string
|
||
}
|
||
|
||
func (e *ErrAdapterConflictPending) Error() string {
|
||
return "adapter " + e.Name + " conflicts with the current " + e.Incumbent +
|
||
"; the incumbent keeps serving until an operator chooses a winner (admin UI → Conflicts)"
|
||
}
|
||
|
||
// ---- 持久化 ----
|
||
|
||
// LoadRouteConflicts 读取持久化的裁决(A 的路径赢家 + B 的适配器赢家)。
|
||
// 缺文件是全新安装的正常态;损坏文件记录后忽略而非致命。
|
||
func (ps *Plugins) LoadRouteConflicts() error {
|
||
path := ps.routeConflictsFile()
|
||
if path == "" {
|
||
return nil
|
||
}
|
||
b, err := os.ReadFile(path)
|
||
if os.IsNotExist(err) {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var pc persistedConflicts
|
||
if err := json.Unmarshal(b, &pc); err != nil {
|
||
return err
|
||
}
|
||
ps.mu.Lock()
|
||
defer ps.mu.Unlock()
|
||
if ps.routeWinners == nil {
|
||
ps.routeWinners = map[string]string{}
|
||
}
|
||
for k, v := range pc.Routes {
|
||
ps.routeWinners[normalizeRoutePath(k)] = v
|
||
}
|
||
if ps.adapterWinners == nil {
|
||
ps.adapterWinners = map[string]string{}
|
||
}
|
||
for k, v := range pc.Adapters {
|
||
ps.adapterWinners[k] = v
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// saveConflictsLocked 落盘两张裁决表。调用方持 ps.mu。
|
||
func (ps *Plugins) saveConflictsLocked() {
|
||
path := ps.routeConflictsFile()
|
||
if path == "" {
|
||
return
|
||
}
|
||
pc := persistedConflicts{}
|
||
if len(ps.routeWinners) > 0 {
|
||
pc.Routes = map[string]string{}
|
||
for k, v := range ps.routeWinners {
|
||
pc.Routes[k] = v
|
||
}
|
||
}
|
||
if len(ps.adapterWinners) > 0 {
|
||
pc.Adapters = map[string]string{}
|
||
for k, v := range ps.adapterWinners {
|
||
pc.Adapters[k] = v
|
||
}
|
||
}
|
||
if pc.Routes == nil && pc.Adapters == nil {
|
||
_ = os.Remove(path)
|
||
return
|
||
}
|
||
writeJSONAtomic(path, pc)
|
||
}
|