Files
ModelRouter/internal/lua/route_conflicts.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

334 lines
11 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 — 服务路径/适配器名冲突裁决(用户要求 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)
}