mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
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 后新版本才真正生效。
This commit is contained in:
333
internal/lua/route_conflicts.go
Normal file
333
internal/lua/route_conflicts.go
Normal file
@ -0,0 +1,333 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user