mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47: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:
261
e2e/conflict_arbitration_e2e_test.go
Normal file
261
e2e/conflict_arbitration_e2e_test.go
Normal file
@ -0,0 +1,261 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeE2EPlugin 落一个插件文件到 e2e 的 plugin_dir(SeedBundled 见空目录才
|
||||
// 落地内置插件,所以先放自定义插件再启动网关)。
|
||||
func writeE2EPlugin(t *testing.T, dir, name, code string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, "plugins", name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(code), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// e2eConfig 返回一份最小 e2e 配置文本(含 plugin_dir)。
|
||||
func e2eConfig(dir, listen string) string {
|
||||
return "listen: " + listen + "\n" +
|
||||
"gateway_keys:\n - sk-e2e-0001\n" +
|
||||
"default_model: AUTO\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"plugin_dir: " + filepath.Join(dir, "plugins") + "\n" +
|
||||
"sources: []\n"
|
||||
}
|
||||
|
||||
// TestConflictArbitrationE2E(用户要求:服务路径冲突不崩溃不拒启 + WebAPI 裁决)
|
||||
//
|
||||
// 真二进制全链路:
|
||||
// 1. 两个插件声明同一条 /api/plugins/http/ 路径 → 两插件都 loaded,启动正常
|
||||
// 2. 请求该路径 → 409,错误信息点名两个候选插件(不静默选边)
|
||||
// 3. GET /api/plugins/conflicts → 列出冲突(路径 + 候选 + 未裁决)
|
||||
// 4. POST /api/plugins/conflicts 裁决 → 持久化
|
||||
// 5. 再请求该路径 → 200,赢家应答({"from":"B"})
|
||||
// 6. /v1/models 等既有路由全程不受影响(旧通路无回归)
|
||||
func TestConflictArbitrationE2E(t *testing.T) {
|
||||
bin := buildBinary(t)
|
||||
dir := t.TempDir()
|
||||
addr := mustAddr(t)
|
||||
|
||||
writeE2EPlugin(t, dir, "plugA.lua", `
|
||||
local P = {}
|
||||
P.name = "plugA"
|
||||
P.version = "1"
|
||||
P.exports = {}
|
||||
P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } }
|
||||
function P.h(req) return { status = 200, json = { from = "A" } } end
|
||||
return P
|
||||
`)
|
||||
writeE2EPlugin(t, dir, "plugB.lua", `
|
||||
local P = {}
|
||||
P.name = "plugB"
|
||||
P.version = "1"
|
||||
P.exports = {}
|
||||
P.http = { { path = "contested", method = "POST", handler = "h", roles = { "admin" } } }
|
||||
function P.h(req) return { status = 200, json = { from = "B" } } end
|
||||
return P
|
||||
`)
|
||||
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := startGateway(t, bin, addr, cfgPath)
|
||||
|
||||
// ① 两个插件都加载成功(冲突不拒载)
|
||||
resp, body := g.do(http.MethodGet, "/api/plugins", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /api/plugins: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
var list struct {
|
||||
Plugins []struct {
|
||||
Name string `json:"name"`
|
||||
Loaded bool `json:"loaded"`
|
||||
} `json:"plugins"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded := map[string]bool{}
|
||||
for _, p := range list.Plugins {
|
||||
loaded[p.Name] = p.Loaded
|
||||
}
|
||||
if !loaded["plugA"] || !loaded["plugB"] {
|
||||
t.Fatalf("冲突的两个插件都应 loaded: %v", loaded)
|
||||
}
|
||||
|
||||
// ② 请求冲突路径 → 409 + 点名候选(不静默选边)
|
||||
resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("未裁决冲突路径应 409, 实得 %d (%s)", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(body, "plugA") || !strings.Contains(body, "plugB") {
|
||||
t.Fatalf("409 应点名两个候选: %s", body)
|
||||
}
|
||||
|
||||
// ③ 冲突列表可见
|
||||
resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
var cl struct {
|
||||
Routes []struct {
|
||||
Path string `json:"path"`
|
||||
Candidates []string `json:"candidates"`
|
||||
Resolved string `json:"resolved"`
|
||||
} `json:"routes"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &cl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cl.Routes) != 1 || cl.Routes[0].Path != "contested" || cl.Routes[0].Resolved != "" {
|
||||
t.Fatalf("冲突形态不对: %+v", cl)
|
||||
}
|
||||
|
||||
// ④ 裁决 plugB 赢
|
||||
arbit, _ := json.Marshal(map[string]string{
|
||||
"kind": "route", "path": "contested", "winner": "plugB",
|
||||
})
|
||||
resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("POST conflicts 裁决: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// ⑤ 再请求 → 200 且赢家应答
|
||||
resp, body = g.do(http.MethodPost, "/api/plugins/http/contested", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("裁决后请求冲突路径应 200, 实得 %d (%s)", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(body, `"from":"B"`) {
|
||||
t.Fatalf("赢家 plugB 应答, 实得 %s", body)
|
||||
}
|
||||
|
||||
// ⑥ 旧通路无回归:/v1/models 正常
|
||||
resp, _ = g.do(http.MethodGet, "/v1/models", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("旧通路 /v1/models 应 200, 实得 %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdapterConflictE2E:插件装与内置同名适配器 → 挂起(现任保留),
|
||||
// /api/plugins/conflicts 列出,裁决 candidate 后插件版生效。
|
||||
func TestAdapterConflictE2E(t *testing.T) {
|
||||
bin := buildBinary(t)
|
||||
dir := t.TempDir()
|
||||
addr := mustAddr(t)
|
||||
|
||||
writeE2EPlugin(t, dir, "shadow.lua", `
|
||||
local P = {}
|
||||
P.name = "shadow"
|
||||
P.version = "1"
|
||||
P.exports = { "install" }
|
||||
function P.install(args)
|
||||
local src = 'local a={} a.name="openai" a.version="SHADOW_V1" a.endpoint="/x" return a'
|
||||
local name, err = __llmsproxy_adapters.install("openai", src)
|
||||
return { name = name, err = err }
|
||||
end
|
||||
return P
|
||||
`)
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(cfgPath, []byte(e2eConfig(dir, addr)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := startGateway(t, bin, addr, cfgPath)
|
||||
|
||||
// ① 安装 → 返回 err(pending 语义),内置版本不变
|
||||
resp, body := g.do(http.MethodPost, "/api/plugins/shadow/call/install", "{}", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("call install: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
// Call 端点把插件返回值包在 {"result": …} 里;挂起语义的断言在 result 层做。
|
||||
var wrapped struct {
|
||||
Result struct {
|
||||
Name *string `json:"name"`
|
||||
Err string `json:"err"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &wrapped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := wrapped.Result
|
||||
if out.Name != nil {
|
||||
t.Fatalf("挂起时不得返回生效名: %s", body)
|
||||
}
|
||||
if !strings.Contains(out.Err, "openai") || !strings.Contains(out.Err, "conflict") {
|
||||
t.Fatalf("错误应点名冲突: %q", out.Err)
|
||||
}
|
||||
|
||||
// ② 冲突列表可见
|
||||
resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET conflicts: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
var cl struct {
|
||||
Adapters []struct {
|
||||
Name string `json:"name"`
|
||||
Candidate string `json:"candidate"`
|
||||
} `json:"adapters"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &cl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cl.Adapters) != 1 || cl.Adapters[0].Name != "openai" || cl.Adapters[0].Candidate != "shadow" {
|
||||
t.Fatalf("冲突形态不对: %+v", cl)
|
||||
}
|
||||
|
||||
// ③ 适配器列表里 openai 仍是内置版本(现任继续服务,不被静默覆盖)
|
||||
assertOpenAIVersion := func(want string, msg string) {
|
||||
t.Helper()
|
||||
resp, body := g.do(http.MethodGet, "/api/adapters", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("GET /api/adapters: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
var al struct {
|
||||
Adapters []struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
} `json:"adapters"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &al); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, a := range al.Adapters {
|
||||
if a.Name == "openai" {
|
||||
if a.Version != want {
|
||||
t.Fatalf("%s: openai 版本应 %q, 实得 %q", msg, want, a.Version)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s: openai 适配器不存在: %s", msg, body)
|
||||
}
|
||||
assertOpenAIVersion("2.0.0", "现任内置必须继续服务(不被静默覆盖)")
|
||||
|
||||
// ④ 裁决 candidate → 插件版生效
|
||||
arbit, _ := json.Marshal(map[string]string{
|
||||
"kind": "adapter", "name": "openai", "winner": "candidate",
|
||||
})
|
||||
if resp, body = g.do(http.MethodPost, "/api/plugins/conflicts", string(arbit), true); resp.StatusCode != 200 {
|
||||
t.Fatalf("裁决 candidate: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// ⑤ 适配器版本已切换 + 冲突清空
|
||||
assertOpenAIVersion("SHADOW_V1", "裁决后插件版应生效")
|
||||
resp, body = g.do(http.MethodGet, "/api/plugins/conflicts", "", true)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatal(resp.StatusCode)
|
||||
}
|
||||
if strings.Contains(body, `"name":"openai"`) && strings.Contains(body, `"adapters":[{`) {
|
||||
t.Fatalf("裁决后冲突应清除: %s", body)
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@ -45,12 +46,25 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
matches := ps.HTTPHandlers()
|
||||
var chosen *lua.HTTPHandler
|
||||
var params map[string]string
|
||||
// contenders holds the handlers that matched at the current best depth with
|
||||
// the SAME literal route pattern — i.e. two plugins that both declared this
|
||||
// exact path. That is the contested case the operator must arbitrate (user
|
||||
// requirement A): rather than let load order pick a silent winner, a
|
||||
// contested path with no recorded decision returns 409 naming the candidates.
|
||||
var contenders []lua.HTTPHandler
|
||||
for i := range matches {
|
||||
h := matches[i]
|
||||
if p, ok := matchHandlerPath(h.Path, rest); ok {
|
||||
if chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path) {
|
||||
chosen, params = &h, p
|
||||
}
|
||||
p, ok := matchHandlerPath(h.Path, rest)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path):
|
||||
chosen, params = &matches[i], p
|
||||
contenders = []lua.HTTPHandler{matches[i]}
|
||||
case segmentCount(h.Path) == segmentCount(chosen.Path) && h.Path == chosen.Path:
|
||||
// Same route pattern, a different plugin. The winner is arbitrated below.
|
||||
contenders = append(contenders, matches[i])
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
@ -58,6 +72,13 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
"no plugin serves this path")
|
||||
return
|
||||
}
|
||||
if len(contenders) > 1 {
|
||||
resolved := g.resolveContestedRoute(w, ps, chosen.Path, contenders)
|
||||
if resolved == nil {
|
||||
return // 409 already written, naming the candidates
|
||||
}
|
||||
chosen = resolved
|
||||
}
|
||||
|
||||
if !strings.EqualFold(r.Method, chosen.Method) {
|
||||
w.Header().Set("Allow", chosen.Method)
|
||||
@ -98,6 +119,44 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
lua.WriteHandlerResponse(w, resp)
|
||||
}
|
||||
|
||||
// resolveContestedRoute arbitrates a service path that more than one plugin
|
||||
// declared (user requirement A). It returns the winning handler, or nil after
|
||||
// writing a 409 when the operator has not yet chosen.
|
||||
//
|
||||
// This is the crux of the "report, don't crash, don't silently pick" rule: with
|
||||
// two plugins both serving e.g. "stats", load order used to decide the winner.
|
||||
// Now an unresolved contest is a 409 that NAMES the candidates, and the operator
|
||||
// picks one in the WebUI (POST /api/plugins/conflicts). A recorded decision is
|
||||
// honoured here on every request thereafter.
|
||||
func (g *Gateway) resolveContestedRoute(w http.ResponseWriter, ps *lua.Plugins, path string, contenders []lua.HTTPHandler) *lua.HTTPHandler {
|
||||
winner := ps.RouteWinner(path)
|
||||
if winner != "" {
|
||||
for i := range contenders {
|
||||
if contenders[i].Plugin == winner {
|
||||
return &contenders[i]
|
||||
}
|
||||
}
|
||||
// A decision exists but points at a plugin that no longer serves this
|
||||
// path (it was removed or edited). Fall through to 409 rather than honour
|
||||
// a stale winner that cannot answer.
|
||||
}
|
||||
names := make([]string, 0, len(contenders))
|
||||
for i := range contenders {
|
||||
names = append(names, contenders[i].Plugin)
|
||||
}
|
||||
sort.Strings(names)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
writeJSON(w, http.StatusConflict, map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"type": "route_conflict",
|
||||
"message": "service path \"" + path + "\" is claimed by " + strconv.Itoa(len(names)) + " plugins (" + strings.Join(names, ", ") + "); an operator must choose one in the admin UI (Conflicts) or POST /api/plugins/conflicts",
|
||||
"path": path,
|
||||
"candidates": names,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlerAccessHint explains, in the refusal, exactly which declaration would
|
||||
// open this endpoint. A 403 with no reason is the kind of thing an operator
|
||||
// opens the source to work out, and then the fix is a guess.
|
||||
|
||||
@ -82,6 +82,57 @@ func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/plugins")
|
||||
path = strings.Trim(path, "/")
|
||||
|
||||
// /api/plugins/conflicts — the arbitration surface (user requirement).
|
||||
// GET lists every contested route (A) and every suspended adapter-name
|
||||
// install (B), each with its candidates/incumbent and any recorded
|
||||
// decision.
|
||||
// POST {kind,path|name,winner} records the operator's choice and persists
|
||||
// it. Must be routed BEFORE the single-segment {name} handler below,
|
||||
// which would otherwise read "conflicts" as a plugin name.
|
||||
if path == "conflicts" {
|
||||
if reqRole(r.Context()) != "admin" {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"routes": ps.RouteConflicts(),
|
||||
"adapters": ps.AdapterConflicts(),
|
||||
})
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Kind string `json:"kind"` // "route" or "adapter"
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Winner string `json:"winner"` // route: plugin name; adapter: "incumbent"|"candidate"
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
switch body.Kind {
|
||||
case "route":
|
||||
if err := ps.ResolveRouteConflict(body.Path, body.Winner); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "conflict_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "path": body.Path, "winner": body.Winner})
|
||||
case "adapter":
|
||||
if err := ps.ResolveAdapterConflict(body.Name, body.Winner); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "conflict_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": body.Name, "winner": body.Winner})
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "kind must be \"route\" or \"adapter\"")
|
||||
}
|
||||
default:
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// /api/plugins/{name}/state
|
||||
if strings.HasSuffix(path, "/state") {
|
||||
name := strings.TrimSuffix(path, "/state")
|
||||
|
||||
@ -4856,6 +4856,14 @@ function afterChainEdit() {
|
||||
// on_disk rather than the loaded set so a plugin that FAILED to load
|
||||
// still appears, with its error — otherwise a syntax error looks
|
||||
// identical to "the plugin is not there".
|
||||
async function resolveRoute(path, winner) {
|
||||
await api("/api/plugins/conflicts", { method: "POST", body: JSON.stringify({ kind: "route", path, winner }) });
|
||||
renderPlugins();
|
||||
}
|
||||
async function resolveAdapter(name, winner) {
|
||||
await api("/api/plugins/conflicts", { method: "POST", body: JSON.stringify({ kind: "adapter", name, winner }) });
|
||||
renderPlugins();
|
||||
}
|
||||
async function renderPlugins() {
|
||||
let j;
|
||||
try {
|
||||
@ -4908,6 +4916,32 @@ function afterChainEdit() {
|
||||
})
|
||||
.join("");
|
||||
|
||||
// 冲突裁决(用户要求:服务路径/适配器名冲突不崩溃不拒启,运维在此选一保留)
|
||||
let confHtml = "";
|
||||
try {
|
||||
const cj = await api("/api/plugins/conflicts");
|
||||
const rc = cj.routes || [];
|
||||
const ac = cj.adapters || [];
|
||||
if (rc.length || ac.length) {
|
||||
const rows2 = []
|
||||
.concat(rc.map((c) => `
|
||||
<tr><td><span class="tag tag-red">路径</span> <code>${esc(c.path)}</code></td>
|
||||
<td>${c.candidates.map(esc).join(" vs ")}</td>
|
||||
<td>${c.resolved ? `<span class="tag tag-green">已选 ${esc(c.resolved)}</span>` : `<span class="tag tag-red">待裁决</span>`}</td>
|
||||
<td>${c.candidates.map((n) => `<button class="small" onclick="resolveRoute('${escAttr(c.path)}','${escAttr(n)}')">用 ${esc(n)}</button>`).join(" ")}</td></tr>`))
|
||||
.concat(ac.map((c) => `
|
||||
<tr><td><span class="tag">适配器</span> <code>${esc(c.name)}</code></td>
|
||||
<td>${esc(c.incumbent)}(现任) vs 插件 ${esc(c.candidate)}(候选)</td>
|
||||
<td>${c.resolved ? `<span class="tag tag-green">已选 ${esc(c.resolved)}</span>` : `<span class="tag tag-red">待裁决</span>`}</td>
|
||||
<td><button class="small" onclick="resolveAdapter('${escAttr(c.name)}','incumbent')">保留现任</button>
|
||||
<button class="small" onclick="resolveAdapter('${escAttr(c.name)}','candidate')">用插件版</button></td></tr>`))
|
||||
.join("");
|
||||
confHtml = `<div class="card" style="border-color:var(--danger)"><h2>冲突裁决(${rc.length + ac.length})</h2>
|
||||
<p class="muted">两个插件/适配器声明了同一条服务路径或同一个适配器名。未裁决的路径返回 409,撞名的适配器由现任继续服务。请选一个保留。</p>
|
||||
<div class="tbl-wrap"><table><tr><th>冲突项</th><th>候选</th><th>状态</th><th>裁决</th></tr>${rows2}</table></div></div>`;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Hook errors: a plugin that throws leaves no visible trace anywhere
|
||||
// else, so without this the symptom is "the feature silently doesn't
|
||||
// work".
|
||||
@ -4918,7 +4952,7 @@ function afterChainEdit() {
|
||||
)
|
||||
.join("");
|
||||
|
||||
$("#tab-plugins").innerHTML = `
|
||||
$("#tab-plugins").innerHTML = `${confHtml}
|
||||
<div class="card"><h2>${t("plTitle")} (${(j.on_disk || []).length})</h2>
|
||||
<div class="tbl-wrap"><table><tr>
|
||||
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th>${t("plVisibility")}</th><th>${t("plEndpoints")}</th><th>${t("plKernel")}</th><th></th>
|
||||
|
||||
287
internal/lua/conflict_arbitration_test.go
Normal file
287
internal/lua/conflict_arbitration_test.go
Normal file
@ -0,0 +1,287 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,7 @@ package lua
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
@ -103,35 +104,65 @@ func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error) {
|
||||
if ps.vm == nil {
|
||||
return "", fmt.Errorf("install_adapter: no adapter VM available")
|
||||
}
|
||||
// Rule 3 first: an adapter another plugin already owns is off-limits.
|
||||
// A name conflict used to be a hard rejection (owned by another plugin, or
|
||||
// shadows a built-in). The user now wants it SUSPENDED for operator
|
||||
// arbitration instead of refused outright: the incumbent keeps serving, the
|
||||
// candidate waits in the conflicts list, and the operator picks a winner in
|
||||
// the WebUI. The install still returns a (typed) error so the caller knows it
|
||||
// did not take effect — but it is ErrAdapterConflictPending, not a dead end.
|
||||
incumbent := ""
|
||||
if owner := ps.adapterOwner().ownedBy(name); owner != "" && owner != plugin {
|
||||
return "", fmt.Errorf("install_adapter: adapter %q is owned by plugin %q and cannot be replaced by %q",
|
||||
name, owner, plugin)
|
||||
incumbent = "plugin:" + owner
|
||||
} else if containsNameFold(ps.vm.bundledNames(), name) {
|
||||
incumbent = "operator (built-in)"
|
||||
}
|
||||
// Rules 1+2 (path safety + no shadowing built-ins) live in
|
||||
// InstallPluginAdapter so there is exactly one enforcement point.
|
||||
if incumbent != "" {
|
||||
if ps.recordAdapterConflict(name, incumbent, plugin, code) {
|
||||
// The operator already chose THIS candidate in a prior session. Install
|
||||
// for real via LoadAdapterSource, which bypasses the reserved-name gate
|
||||
// — that explicit operator override is exactly what the arbitration is
|
||||
// FOR, and re-suspending it on every restart would be a loop.
|
||||
if err := ps.vm.LoadAdapterSource(name, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ps.finishInstall(plugin, name), nil
|
||||
}
|
||||
return "", &ErrAdapterConflictPending{Name: name, Incumbent: incumbent}
|
||||
}
|
||||
// No conflict: normal install path. Rules 1+2 (path safety + no shadowing
|
||||
// built-ins) live in InstallPluginAdapter so there is exactly one
|
||||
// enforcement point. (The shadow check there is now unreachable for a
|
||||
// genuine built-in — it is caught above and suspended — but kept as the
|
||||
// single authority for path safety and any future reserved set.)
|
||||
if err := ps.vm.InstallPluginAdapter(name, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Ownership is recorded under the name sources will actually reference. A
|
||||
// script may declare a name different from the registration key, and it is
|
||||
// the declared name that ends up in the registry — recording it under the
|
||||
// key would leave the adapter un-owned and therefore mutable by anyone.
|
||||
// Ownership is recorded under the name sources will actually reference. A
|
||||
// script may declare a name different from the registration key, and it is
|
||||
// the declared name that ends up in the registry — recording it under the
|
||||
// key would leave the adapter un-owned and therefore mutable by anyone.
|
||||
//
|
||||
// Both names are recorded: the registration key so a later update through
|
||||
// the same key still matches, and the declared name so a lookup by the name
|
||||
// a source references also matches. Both entries are cleared together on
|
||||
// remove.
|
||||
return ps.finishInstall(plugin, name), nil
|
||||
}
|
||||
|
||||
// finishInstall records adapter ownership under BOTH the registration key and
|
||||
// the declared name (a script may declare a name different from the key, and it
|
||||
// is the declared name sources reference — recording only the key would leave
|
||||
// the adapter un-owned and therefore mutable by anyone). Both entries are
|
||||
// cleared together on remove.
|
||||
func (ps *Plugins) finishInstall(plugin, name string) string {
|
||||
effective := ps.vm.EffectiveAdapterName(name)
|
||||
ps.adapterOwner().set(effective, plugin)
|
||||
if effective != name {
|
||||
ps.adapterOwner().set(name, plugin)
|
||||
}
|
||||
return effective, nil
|
||||
return effective
|
||||
}
|
||||
|
||||
// containsNameFold reports whether name is in names (case-insensitive, matching
|
||||
// ValidateAdapterName's reserved-name comparison).
|
||||
func containsNameFold(names []string, name string) bool {
|
||||
for _, n := range names {
|
||||
if strings.EqualFold(n, name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateAdapter replaces an adapter a plugin previously installed — the token
|
||||
|
||||
@ -292,6 +292,23 @@ type Plugins struct {
|
||||
// is state and not a convention).
|
||||
adapterOwn *adapterOwnership
|
||||
adapterOwnOnce sync.Once
|
||||
// routeWinners records the operator's arbitration for contested HTTP service
|
||||
// paths: a path two+ plugins declared, resolved to one winning plugin. Keyed
|
||||
// by normalized path. See route_conflicts.go — a contested path that is NOT
|
||||
// in here is unresolved and the dispatcher returns 409 rather than silently
|
||||
// picking a load-order winner. Persisted in .route-conflicts.json.
|
||||
routeWinners map[string]string
|
||||
// adapterWinners records the operator's arbitration for an adapter NAME two
|
||||
// things claimed (user requirement B): "incumbent" keeps the built-in / first
|
||||
// plugin, "candidate:<plugin>" lets that plugin's version win. Persisted in
|
||||
// .route-conflicts.json beside routeWinners. See route_conflicts.go.
|
||||
adapterWinners map[string]string
|
||||
// adapterConflicts holds adapter-name installs currently SUSPENDED pending an
|
||||
// operator decision: a plugin tried to install an adapter whose name an
|
||||
// incumbent already holds. The incumbent keeps serving; the candidate waits
|
||||
// here (with its code) until arbitrated. In-memory only — a restart replays
|
||||
// the install, which re-suspends unless a persisted winner says otherwise.
|
||||
adapterConflicts map[string]*AdapterConflict
|
||||
// shards runs exported functions on private VMs so blocking work can be
|
||||
// genuinely parallel. See plugin_shard.go for why the shared VM's lock makes
|
||||
// concurrency impossible and what a shard must therefore never touch.
|
||||
@ -583,6 +600,12 @@ func (ps *Plugins) LoadDir() error {
|
||||
if err := ps.LoadKernelPermissions(); err != nil {
|
||||
log.Printf("[core] plugin kernel permissions not loaded: %v", err)
|
||||
}
|
||||
// Route-conflict arbitrations are operator policy too: who wins a service
|
||||
// path two plugins both declared. Must be in place before the first request
|
||||
// is dispatched, or a contested path would 409 until the operator re-chose.
|
||||
if err := ps.LoadRouteConflicts(); err != nil {
|
||||
log.Printf("[core] plugin route conflicts not loaded: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(ps.dir); os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@ -841,12 +841,35 @@ func (v *VM) LoadAdapterSource(name, code string) error {
|
||||
// it controls while shipping an adapter that declares the name sources
|
||||
// reference). Without this, updating that adapter would install a second
|
||||
// pool and leave the old one serving traffic.
|
||||
//
|
||||
// The key can live under EITHER name (this is the "reload replaces the same
|
||||
// pool entry rather than silently registering a second one" rule):
|
||||
//
|
||||
// - pools[static.name] — the pool filed under the DECLARED name (the
|
||||
// common case, and the only case when a plugin installs under a key that
|
||||
// matches its script's own name), or
|
||||
// The pool registry is keyed by the DECLARED name (static.name), always:
|
||||
// first install files pools[static.name], and a reload replaces that same
|
||||
// slot. registeredAs only RECORDS the registration key for reverse lookups
|
||||
// (EffectiveAdapterName scans for it); it is not a second key space.
|
||||
//
|
||||
// An earlier version redirected the key to prev.static.registeredAs here,
|
||||
// assuming pools were filed under the registration key — but first installs
|
||||
// file under the declared name, so that redirect created a SECOND pool and
|
||||
// left the old one serving whenever key≠declared (a real bug: a v2 install
|
||||
// via arbitration left v1 serving, and UpdateAdapter had the same hole).
|
||||
key := static.name
|
||||
if prev, ok := v.pools[key]; ok && prev.static.registeredAs != "" {
|
||||
key = prev.static.registeredAs
|
||||
}
|
||||
old := v.pools[key]
|
||||
p := newAdapterPool(static.name, code, *static)
|
||||
// The new pool MUST be filed under the SAME slot as the old one (key), and
|
||||
// named after that slot, so a later lookup by either the DECLARED name or
|
||||
// the REGISTRATION KEY finds the one live pool. Naming it after static.name
|
||||
// would move the pool to the declared-name slot when the incumbent lived
|
||||
// under the registration key, leaving the registration-key slot pointing at
|
||||
// the stale (old) pool that shutdown() then tears down — callers reading by
|
||||
// the registration key see "adapter missing", callers by declared name see
|
||||
// a pool that isn't the one serving. key is the stable identity; the
|
||||
// declared name is reachable through static.name.
|
||||
p := newAdapterPool(key, code, *static)
|
||||
p.setMax(maxW)
|
||||
// Carry the revision forward so the new source keeps counting ABOVE the old
|
||||
// one. A cache keyed on (vm, revision, name) must see a value different
|
||||
|
||||
Reference in New Issue
Block a user