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 后新版本才真正生效。
294 lines
10 KiB
Go
294 lines
10 KiB
Go
// Package lua — 插件适配器管理桥接(R4-a / Phase 3)
|
||
//
|
||
// 用户需求:插件可安装、运行时修改适配器,以支持复杂认证与 token 轮换;
|
||
// 适配器本身不需要持续运行(它是无状态请求级弹性池)。
|
||
//
|
||
// 插件(长期运行)──定期/事件驱动──> install/update/remove_adapter
|
||
// │
|
||
// ▼
|
||
// 内核重建该适配器的池(LoadAdapterSource)
|
||
// │
|
||
// ▼
|
||
// 后续请求自然使用新逻辑
|
||
//
|
||
// 三条安全规则(与 R6 同源):
|
||
//
|
||
// 1. 路径安全:名字必须通过 ValidateAdapterName(无穿越/绝对路径/空白)
|
||
// 2. 不得影子内置:插件不能注册与内置适配器同名的适配器
|
||
// (用 VM.InstallPluginAdapter,运维仍可自行覆盖)
|
||
// 3. 归属可查:记录每个适配器的 owner("plugin:名字" 或 "operator"),
|
||
// 于是「插件只能改自己装的」这条规则是可判定的,而不是靠约定
|
||
//
|
||
// 第 3 条是必要的:没有归属信息时,"插件 A 改插件 B 装的适配器" 无法区分
|
||
// 于合法的自身更新,会导致一个插件能劫持另一个插件的认证逻辑。
|
||
package lua
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
|
||
golua "github.com/aarzilli/golua/lua"
|
||
)
|
||
|
||
// AdapterOwner 标识一个适配器由谁安装。
|
||
const (
|
||
OwnerOperator = "operator" // 磁盘/首启 seed/管理员上传
|
||
OwnerPlugin = "plugin" // 由某个插件经桥接安装
|
||
)
|
||
|
||
// adapterOwnership records which plugin installed each adapter.
|
||
//
|
||
// This is the state that makes rule 3 enforceable: InstallPluginAdapter marks
|
||
// the adapter as owned by that plugin, and a later update from a DIFFERENT
|
||
// plugin is refused. An adapter with no owner is the operator's, and no plugin
|
||
// may touch it.
|
||
type adapterOwnership struct {
|
||
mu sync.RWMutex
|
||
// owner maps adapter name -> installing plugin name (only for plugin-owned).
|
||
owner map[string]string
|
||
}
|
||
|
||
// ownedBy reports the plugin that owns name, or "" when it is operator-owned.
|
||
func (o *adapterOwnership) ownedBy(name string) string {
|
||
o.mu.RLock()
|
||
defer o.mu.RUnlock()
|
||
return o.owner[name]
|
||
}
|
||
|
||
func (o *adapterOwnership) set(name, plugin string) {
|
||
o.mu.Lock()
|
||
defer o.mu.Unlock()
|
||
if o.owner == nil {
|
||
o.owner = map[string]string{}
|
||
}
|
||
o.owner[name] = plugin
|
||
}
|
||
|
||
func (o *adapterOwnership) clear(name string) {
|
||
o.mu.Lock()
|
||
defer o.mu.Unlock()
|
||
delete(o.owner, name)
|
||
}
|
||
|
||
// listOwned returns the adapter names a given plugin owns (for UI/diagnostics).
|
||
func (o *adapterOwnership) listOwned(plugin string) []string {
|
||
o.mu.RLock()
|
||
defer o.mu.RUnlock()
|
||
var out []string
|
||
for name, p := range o.owner {
|
||
if p == plugin {
|
||
out = append(out, name)
|
||
}
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// Plugins carries an adapter-ownership table.
|
||
func (ps *Plugins) adapterOwner() *adapterOwnership {
|
||
ps.adapterOwnOnce.Do(func() { ps.adapterOwn = &adapterOwnership{} })
|
||
return ps.adapterOwn
|
||
}
|
||
|
||
// InstallAdapter registers an adapter on behalf of a plugin.
|
||
//
|
||
// Returns the name that was actually registered — an adapter script may declare
|
||
// a different `name` than the file/registration key, and the declared name is
|
||
// what sources must reference. Callers get it back so they can update config.
|
||
func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error) {
|
||
if plugin == "" {
|
||
return "", fmt.Errorf("install_adapter: caller plugin is unknown")
|
||
}
|
||
if ps.vm == nil {
|
||
return "", fmt.Errorf("install_adapter: no adapter VM available")
|
||
}
|
||
// 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 {
|
||
incumbent = "plugin:" + owner
|
||
} else if containsNameFold(ps.vm.bundledNames(), name) {
|
||
incumbent = "operator (built-in)"
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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
|
||
// rotation path. The plugin gets a NEW pool; the old one is shut down, so an
|
||
// in-flight request either uses the old or the new code, never a mixture.
|
||
//
|
||
// Only the owning plugin may update. Updating an operator-installed adapter is
|
||
// refused: the operator's file on disk would then disagree with what the running
|
||
// gateway executes, and the next restart would silently revert the "update".
|
||
func (ps *Plugins) UpdateAdapter(plugin, name, code string) error {
|
||
if plugin == "" {
|
||
return fmt.Errorf("update_adapter: caller plugin is unknown")
|
||
}
|
||
owner := ps.adapterOwner().ownedBy(name)
|
||
if owner == "" {
|
||
return fmt.Errorf("update_adapter: adapter %q is operator-installed; "+
|
||
"only adapters a plugin installed can be updated at runtime", name)
|
||
}
|
||
if owner != plugin {
|
||
return fmt.Errorf("update_adapter: adapter %q is owned by plugin %q, not %q",
|
||
name, owner, plugin)
|
||
}
|
||
if err := ps.vm.InstallPluginAdapter(name, code); err != nil {
|
||
return err
|
||
}
|
||
// Ownership is unchanged: same plugin, new version.
|
||
return nil
|
||
}
|
||
|
||
// RemoveAdapter removes an adapter a plugin installed. Operator-installed
|
||
// adapters are refused (same reasoning as UpdateAdapter).
|
||
func (ps *Plugins) RemoveAdapter(plugin, name string) error {
|
||
owner := ps.adapterOwner().ownedBy(name)
|
||
if owner == "" {
|
||
return fmt.Errorf("remove_adapter: adapter %q is operator-installed; "+
|
||
"use DELETE /api/adapters/%s", name, name)
|
||
}
|
||
if owner != plugin {
|
||
return fmt.Errorf("remove_adapter: adapter %q is owned by plugin %q, not %q",
|
||
name, owner, plugin)
|
||
}
|
||
ps.vm.RemoveAdapter(name)
|
||
ps.adapterOwner().clear(name)
|
||
return nil
|
||
}
|
||
|
||
// PluginAdapters lists the adapters a plugin owns (its token-rotation surface).
|
||
func (ps *Plugins) PluginAdapters(plugin string) []string {
|
||
return ps.adapterOwner().listOwned(plugin)
|
||
}
|
||
|
||
// AdapterOwnerOf reports the owning plugin of an adapter, or "" if operator-owned.
|
||
func (ps *Plugins) AdapterOwnerOf(name string) string {
|
||
return ps.adapterOwner().ownedBy(name)
|
||
}
|
||
|
||
// ---- Lua 绑定(R4:插件在运行时改自己的适配器)----
|
||
|
||
// adapterBridgeGlobal 暴露给插件 Lua 的适配器管理表。
|
||
//
|
||
// 用一张表而不是三个顶层全局,是为了少占插件可见的全局命名空间。表用
|
||
// buildJSONTable 的同一套写法(NewTable + PushGoClosure + SetField),
|
||
// 该写法在 json 表上已被长期验证。
|
||
const adapterBridgeGlobal = "__llmsproxy_adapters"
|
||
|
||
// registerAdapterBridge 把适配器管理表装进插件的 Lua 状态。
|
||
//
|
||
// name 是**该插件自己的启动名**,随每次调用一起交给 Go 侧桥接 —— 归属校验
|
||
// 因此来自启动时的固定身份,而不是从 Lua 状态里现读(那样一个分片或任何持有
|
||
// 已编译脚本的调用者就能冒充别的插件)。
|
||
func registerAdapterBridge(L *golua.State, ps *Plugins, name string) {
|
||
L.NewTable()
|
||
install := luaInstallAdapter(L, ps, name)
|
||
L.PushGoClosure(install)
|
||
L.SetField(-2, "install")
|
||
update := luaUpdateAdapter(L, ps, name)
|
||
L.PushGoClosure(update)
|
||
L.SetField(-2, "update")
|
||
remove := luaRemoveAdapter(L, ps, name)
|
||
L.PushGoClosure(remove)
|
||
L.SetField(-2, "remove")
|
||
L.SetGlobal(adapterBridgeGlobal)
|
||
}
|
||
|
||
func luaInstallAdapter(L *golua.State, ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
adapterName := L.ToString(1)
|
||
code := L.ToString(2)
|
||
effective, err := ps.InstallAdapter(name, adapterName, code)
|
||
if err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
L.PushString(effective)
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
func luaUpdateAdapter(L *golua.State, ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
adapterName := L.ToString(1)
|
||
code := L.ToString(2)
|
||
if err := ps.UpdateAdapter(name, adapterName, code); err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
L.PushBoolean(true)
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
func luaRemoveAdapter(L *golua.State, ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
adapterName := L.ToString(1)
|
||
if err := ps.RemoveAdapter(name, adapterName); err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
L.PushBoolean(true)
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|