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:
JianFeeeee
2026-10-12 12:32:10 +08:00
parent 6b6f9526ff
commit 5948ad19ca
9 changed files with 1130 additions and 28 deletions

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