mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(lua): 插件适配器管理桥接(R4-a)—— 安装/改写/删除,支撑 token 轮换
用户需求:插件可安装、运行时修改适配器以支持复杂认证与 token 轮换;
适配器本身不需要持续运行(它是无状态请求级弹性池)。
插件(长期运行)─定期/事件─> install/update/remove_adapter
│
▼
内核重建该适配器的池(新池先发布,旧池后回收)
│
▼
后续请求自然使用新逻辑
三条安全规则(与 R6 同源):
1. 路径安全:ValidateAdapterName(无穿越/绝对路径/空白)
2. 不得影子内置:插件不能注册与内置同名适配器(运维仍可自行覆盖)
3. 归属可查:owner 表记录每个适配器由谁安装,于是"插件只能改自己装的"
是可判定的,而非靠约定。没有归属信息时,插件 A 能劫持插件 B 的认证逻辑
而无法被察觉。
修掉两个开发中暴露的真实缺陷:
1. 池替换窗口:LoadAdapterSource 原先在 v.mu 内先 shutdown 旧池再装新池,
窗口期内请求拿到 "pool closed"。改为**新池先发布、旧池后回收**。
2. 并发轮换仍失败:多个 goroutine 同时轮换时,单次重试会连续落在刚被下个
轮换关闭的池上。acquireAcrossSwap 改为按**墙钟**(2s 上限)+1ms 退避
重试新池 —— 轮换是运维的意图,落在微秒窗口里的请求不该因此失败。
上限保证适配器真消失时仍会及时报错而非空转。
3. 声明名 vs 注册键:脚本可声明与注册键不同的 name(插件按自己控制的键注册,
但适配器声明源真正引用的名字)。归属表同时记两个键,删除时一并清理;
EffectiveAdapterName 在声明名下也能查到池。
测试:11 个(安装/轮换/影子内置/跨插件窃取/碰运维适配器/路径穿越/删除/
归属列举/并发 8×20 压力/语法错误安装即拒/声明名上报)。
注:internal/gateway 的变异测试 harness 拒绝在脏工作区运行(设计如此),
故本提交后它才可运行。
This commit is contained in:
187
internal/lua/plugin_adapter_bridge.go
Normal file
187
internal/lua/plugin_adapter_bridge.go
Normal file
@ -0,0 +1,187 @@
|
||||
// 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"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 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")
|
||||
}
|
||||
// Rule 3 first: an adapter another plugin already owns is off-limits.
|
||||
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)
|
||||
}
|
||||
// Rules 1+2 (path safety + no shadowing built-ins) live in
|
||||
// InstallPluginAdapter so there is exactly one enforcement point.
|
||||
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.
|
||||
effective := ps.vm.EffectiveAdapterName(name)
|
||||
ps.adapterOwner().set(effective, plugin)
|
||||
if effective != name {
|
||||
ps.adapterOwner().set(name, plugin)
|
||||
}
|
||||
return effective, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
264
internal/lua/plugin_adapter_bridge_test.go
Normal file
264
internal/lua/plugin_adapter_bridge_test.go
Normal file
@ -0,0 +1,264 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func bridgeVM(t *testing.T) (*VM, *Plugins) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "adapters")
|
||||
vm := NewVM(dir)
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, filepath.Join(t.TempDir(), "plugins"))
|
||||
return vm, ps
|
||||
}
|
||||
|
||||
const tokenAdapterV1 = `
|
||||
local adapter = {}
|
||||
adapter.name = "myauth"
|
||||
adapter.version = "1.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
function adapter.transform_request(body)
|
||||
return '{"token":"TOKEN_V1"}'
|
||||
end
|
||||
return adapter
|
||||
`
|
||||
|
||||
const tokenAdapterV2 = `
|
||||
local adapter = {}
|
||||
adapter.name = "myauth"
|
||||
adapter.version = "2.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
function adapter.transform_request(body)
|
||||
return '{"token":"TOKEN_V2"}'
|
||||
end
|
||||
return adapter
|
||||
`
|
||||
|
||||
// TestPluginInstallsAdapter 是基础能力:插件装的适配器立即可用。
|
||||
func TestPluginInstallsAdapter(t *testing.T) {
|
||||
vm, ps := bridgeVM(t)
|
||||
if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if !vm.HasFn("myauth", "transform_request") {
|
||||
t.Fatal("安装后适配器未注册")
|
||||
}
|
||||
out, err := vm.Transform("myauth", "transform_request", "{}")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "TOKEN_V1") {
|
||||
t.Errorf("安装的适配器未被使用: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginUpdateAdapterRotatesToken 是核心用例:**token 轮换**。
|
||||
// 插件长期运行,在运行中改写适配器,后续请求用新 token —— 适配器自身
|
||||
// 不跑任何循环。
|
||||
func TestPluginUpdateAdapterRotatesToken(t *testing.T) {
|
||||
vm, ps := bridgeVM(t)
|
||||
if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, _ := vm.Transform("myauth", "transform_request", "{}")
|
||||
if !strings.Contains(first, "TOKEN_V1") {
|
||||
t.Fatalf("初次应带 V1: %s", first)
|
||||
}
|
||||
|
||||
// 轮换:插件改写适配器
|
||||
if err := ps.UpdateAdapter("authtoken", "myauth", tokenAdapterV2); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
second, err := vm.Transform("myauth", "transform_request", "{}")
|
||||
if err != nil {
|
||||
t.Fatalf("改写后适配器不可用: %v", err)
|
||||
}
|
||||
if !strings.Contains(second, "TOKEN_V2") {
|
||||
t.Errorf("轮换后应带 V2: %s", second)
|
||||
}
|
||||
if strings.Contains(second, "TOKEN_V1") {
|
||||
t.Errorf("轮换后仍带旧 V1(池未重建?): %s", second)
|
||||
}
|
||||
// 版本号也应更新,便于运维从 /api/status 看出发生过轮换
|
||||
if v := vm.AdapterVersion("myauth"); v != "2.0" {
|
||||
t.Errorf("适配器版本 = %q, want 2.0", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginCannotShadowBuiltin:插件不能篡改内核自带的适配器。
|
||||
func TestPluginCannotShadowBuiltin(t *testing.T) {
|
||||
_, ps := bridgeVM(t)
|
||||
evil := `local a={} a.name="openai" a.version="EVIL" a.endpoint="/x" return a`
|
||||
if _, err := ps.InstallAdapter("sneaky", "openai", evil); err == nil {
|
||||
t.Fatal("插件装与内置同名的适配器应被拒")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginCannotStealAnotherPluginsAdapter:归属隔离。
|
||||
func TestPluginCannotStealAnotherPluginsAdapter(t *testing.T) {
|
||||
_, ps := bridgeVM(t)
|
||||
if _, err := ps.InstallAdapter("alpha", "shared_auth", tokenAdapterV1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ps.InstallAdapter("beta", "shared_auth", tokenAdapterV2); err == nil {
|
||||
t.Error("插件 B 应无法覆盖插件 A 装的适配器")
|
||||
}
|
||||
if err := ps.UpdateAdapter("beta", "shared_auth", tokenAdapterV2); err == nil {
|
||||
t.Error("插件 B 应无法更新插件 A 的适配器")
|
||||
}
|
||||
if err := ps.RemoveAdapter("beta", "shared_auth"); err == nil {
|
||||
t.Error("插件 B 应无法删除插件 A 的适配器")
|
||||
}
|
||||
// 属主自己可以
|
||||
if err := ps.UpdateAdapter("alpha", "shared_auth", tokenAdapterV2); err != nil {
|
||||
t.Errorf("属主应能更新自己的适配器: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginCannotTouchOperatorAdapter:运维的适配器不可被插件改写。
|
||||
func TestPluginCannotTouchOperatorAdapter(t *testing.T) {
|
||||
_, ps := bridgeVM(t)
|
||||
if err := ps.UpdateAdapter("authtoken", "openai", tokenAdapterV1); err == nil {
|
||||
t.Error("插件更新运维适配器应被拒")
|
||||
}
|
||||
if err := ps.RemoveAdapter("authtoken", "openai"); err == nil {
|
||||
t.Error("插件删除运维适配器应被拒")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginAdapterNameTraversal:路径穿越名在桥接层同样被拒。
|
||||
func TestPluginAdapterNameTraversal(t *testing.T) {
|
||||
_, ps := bridgeVM(t)
|
||||
for _, bad := range []string{"../evil", "/etc/passwd", "has space", ".."} {
|
||||
if _, err := ps.InstallAdapter("authtoken", bad, tokenAdapterV1); err == nil {
|
||||
t.Errorf("桥接层应拒绝路径穿越名 %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginRemoveAdapter 让插件能回收自己的适配器(token 失效/功能下线)。
|
||||
func TestPluginRemoveAdapter(t *testing.T) {
|
||||
vm, ps := bridgeVM(t)
|
||||
if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.RemoveAdapter("authtoken", "myauth"); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if vm.HasFn("myauth", "transform_request") {
|
||||
t.Error("删除后适配器仍在")
|
||||
}
|
||||
if owners := ps.PluginAdapters("authtoken"); len(owners) != 0 {
|
||||
t.Errorf("删除后归属表应清空,实得 %v", owners)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginAdapterOwnershipListing 供 UI/诊断用。
|
||||
func TestPluginAdapterOwnershipListing(t *testing.T) {
|
||||
_, ps := bridgeVM(t)
|
||||
if _, err := ps.InstallAdapter("alpha", "a1", tokenAdapterV1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ps.InstallAdapter("alpha", "a2", tokenAdapterV1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ps.InstallAdapter("beta", "b1", tokenAdapterV1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := ps.PluginAdapters("alpha")
|
||||
if len(got) != 2 || got[0] != "a1" || got[1] != "a2" {
|
||||
t.Errorf("alpha 的适配器 = %v, want [a1 a2]", got)
|
||||
}
|
||||
if o := ps.AdapterOwnerOf("b1"); o != "beta" {
|
||||
t.Errorf("b1 属主 = %q, want beta", o)
|
||||
}
|
||||
if o := ps.AdapterOwnerOf("openai"); o != "" {
|
||||
t.Errorf("内置适配器不应有插件属主,实得 %q", o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentPluginAdapterUpdates 是压力测试:多 goroutine 轮换同一适配器。
|
||||
// 池重建与归属表都要在并发下自洽(不 panic、不串属主)。
|
||||
func TestConcurrentPluginAdapterUpdates(t *testing.T) {
|
||||
vm, ps := bridgeVM(t)
|
||||
if _, err := ps.InstallAdapter("authtoken", "myauth", tokenAdapterV1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const workers, iters = 8, 20
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan string, workers*iters)
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iters; i++ {
|
||||
code := tokenAdapterV1
|
||||
if i%2 == 1 {
|
||||
code = tokenAdapterV2
|
||||
}
|
||||
if err := ps.UpdateAdapter("authtoken", "myauth", code); err != nil {
|
||||
errs <- "update: " + err.Error()
|
||||
return
|
||||
}
|
||||
// 更新后必须仍可用(池重建期间不能出现「找不到适配器」)
|
||||
if _, err := vm.Transform("myauth", "transform_request", "{}"); err != nil {
|
||||
errs <- "update 后适配器不可用: " + err.Error()
|
||||
return
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for e := range errs {
|
||||
t.Error(e)
|
||||
}
|
||||
if o := ps.AdapterOwnerOf("myauth"); o != "authtoken" {
|
||||
t.Errorf("并发后属主应为 authtoken,实得 %q", o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstalledAdapterBodyIsValidJSON 确认插件提供的代码必须真能编译成
|
||||
// 可用适配器 —— 语法错误在安装时就报,而不是等到第一个请求。
|
||||
func TestInstalledAdapterBodyIsValidJSON(t *testing.T) {
|
||||
_, ps := bridgeVM(t)
|
||||
if _, err := ps.InstallAdapter("bad", "broken", "this is not lua ==="); err == nil {
|
||||
t.Error("语法错误的适配器应在安装时被拒")
|
||||
}
|
||||
if _, err := ps.InstallAdapter("bad", "notatable", "return 42"); err == nil {
|
||||
t.Error("未返回 table 的适配器应在安装时被拒")
|
||||
}
|
||||
// 失败的安装不应留下归属记录
|
||||
if o := ps.AdapterOwnerOf("broken"); o != "" {
|
||||
t.Errorf("安装失败却留下了属主记录: %q", o)
|
||||
}
|
||||
if o := ps.AdapterOwnerOf("notatable"); o != "" {
|
||||
t.Errorf("安装失败却留下了属主记录: %q", o)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallAdapterReportsActualName 适配器脚本可声明与注册键不同的 name;
|
||||
// 调用方拿到实际名,便于更新配置。
|
||||
func TestInstallAdapterReportsActualName(t *testing.T) {
|
||||
_, ps := bridgeVM(t)
|
||||
code := `local a={} a.name="declared_name" a.version="1" a.endpoint="/x" return a`
|
||||
got, err := ps.InstallAdapter("p", "registration_key", code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "declared_name" {
|
||||
t.Errorf("返回名 = %q, want declared_name", got)
|
||||
}
|
||||
if o := ps.AdapterOwnerOf("declared_name"); o != "p" {
|
||||
t.Errorf("归属应记在声明名上,实得 %q", o)
|
||||
}
|
||||
var _ = json.Marshal
|
||||
}
|
||||
@ -265,6 +265,11 @@ type Plugins struct {
|
||||
// visibility holds the operator's per-plugin audience overrides, keyed by
|
||||
// plugin name. Consulted by UIForRole and the call endpoint's authorisation.
|
||||
visibility visibilityOverrides
|
||||
// adapterOwn records which plugin installed each adapter, so a plugin can
|
||||
// only update adapters it owns (see plugin_adapter_bridge.go for why this
|
||||
// is state and not a convention).
|
||||
adapterOwn *adapterOwnership
|
||||
adapterOwnOnce sync.Once
|
||||
// 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.
|
||||
|
||||
@ -22,6 +22,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -79,6 +80,10 @@ type staticInfo struct {
|
||||
// An adapter that omits endpoint_stream uses endpoint for both (most do).
|
||||
endpointStream string
|
||||
headers map[string]string
|
||||
// registeredAs is the key the adapter was LOADED under, which can differ
|
||||
// from name when a script declares its own name. Kept so a reload replaces
|
||||
// the same pool entry rather than silently registering a second one.
|
||||
registeredAs string
|
||||
}
|
||||
|
||||
// adapterPool owns the worker states for one adapter. Idle workers are held in
|
||||
@ -396,12 +401,67 @@ func (p *adapterPool) releaseReservation(n int) {
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// acquireAcrossSwap checks out a worker, re-reading the registry whenever the
|
||||
// pool it was handed has already been retired.
|
||||
//
|
||||
// Retry (rather than one-shot) because the caller can lose the race repeatedly
|
||||
// when several goroutines rotate the same adapter at once: each retry can land
|
||||
// on a pool that the NEXT rotation is already closing. The bound is a small
|
||||
// number of attempts, not unbounded — a permanently broken adapter must still
|
||||
// surface an error instead of spinning.
|
||||
func acquireAcrossSwap(v *VM, name string, p *adapterPool) (*worker, error) {
|
||||
// A rotation publishes the new pool BEFORE retiring the old one (see
|
||||
// LoadAdapterSource), so a lookup here can only ever find the newest one.
|
||||
// The wait is what makes this correct under sustained rotation: several
|
||||
// goroutines rewriting the same adapter keep retiring the pool this one
|
||||
// holds, and a caller that gave up at that moment would fail a request for
|
||||
// no reason other than landing in a microsecond-wide window.
|
||||
//
|
||||
// The bound is wall-clock, not attempts, so the behaviour does not change
|
||||
// with contention. It is generous enough to ride out a burst of rotations
|
||||
// and short enough that a genuinely gone adapter still surfaces promptly.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
w, err := p.acquire()
|
||||
if err == nil {
|
||||
return w, nil
|
||||
}
|
||||
if !errors.Is(err, ErrPoolClosed) {
|
||||
return nil, err
|
||||
}
|
||||
fresh := v.pool(name)
|
||||
if fresh == nil {
|
||||
return nil, fmt.Errorf("adapter %s not loaded", name)
|
||||
}
|
||||
p = fresh
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("adapter %s: pool kept being replaced for 2s: %w", name, err)
|
||||
}
|
||||
// Yield briefly: the new pool needs a moment to publish, and spinning
|
||||
// here would burn the CPU that the rotation itself needs.
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// ErrPoolClosed reports that the pool a caller was about to use from was
|
||||
// retired between lookup and acquire — which happens on every adapter hot-swap
|
||||
// (a plugin rotating a token rewrites the adapter, and LoadAdapterSource
|
||||
// replaces its pool).
|
||||
//
|
||||
// It is a sentinel so callers can RETRY against the newly published pool
|
||||
// instead of surfacing an error to the client. Without it, a token rotation
|
||||
// under load produces a burst of "adapter X pool closed" failures — the
|
||||
// rotation is the operator's intent, and the window is microseconds wide, so
|
||||
// failing the request that happened to land there is strictly worse than
|
||||
// re-reading the registry.
|
||||
var ErrPoolClosed = errors.New("adapter pool closed")
|
||||
|
||||
func (p *adapterPool) acquire() (*worker, error) {
|
||||
p.mu.Lock()
|
||||
for {
|
||||
if p.closed {
|
||||
p.mu.Unlock()
|
||||
return nil, fmt.Errorf("adapter %s pool closed", p.name)
|
||||
return nil, fmt.Errorf("adapter %s: %w", p.name, ErrPoolClosed)
|
||||
}
|
||||
if n := len(p.idle); n > 0 {
|
||||
w := p.idle[n-1]
|
||||
@ -776,7 +836,16 @@ func (v *VM) LoadAdapterSource(name, code string) error {
|
||||
v.mu.RUnlock()
|
||||
|
||||
v.mu.Lock()
|
||||
old := v.pools[static.name]
|
||||
// A reload must also retire an adapter that was loaded under a DIFFERENT
|
||||
// name than the one this script declares (a plugin can register under a key
|
||||
// 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.
|
||||
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)
|
||||
p.setMax(maxW)
|
||||
// Carry the revision forward so the new source keeps counting ABOVE the old
|
||||
@ -795,11 +864,16 @@ func (v *VM) LoadAdapterSource(name, code string) error {
|
||||
p.revision = old.revision + 1
|
||||
old.mu.Unlock()
|
||||
}
|
||||
v.pools[key] = p
|
||||
v.mu.Unlock()
|
||||
// shutdown() happens AFTER v.mu is released, and the new pool is already
|
||||
// published, so a request arriving in between sees the new code. Shutting
|
||||
// down first would open a window where the adapter is neither old nor new:
|
||||
// concurrent rotations then fail with "adapter X pool closed" — the exact
|
||||
// failure a token-rotation plugin would hit under load.
|
||||
if old != nil {
|
||||
old.shutdown()
|
||||
}
|
||||
v.pools[static.name] = p
|
||||
v.mu.Unlock()
|
||||
v.startJanitor()
|
||||
return nil
|
||||
}
|
||||
@ -873,12 +947,16 @@ func (v *VM) writeBundledAdapters() error {
|
||||
}
|
||||
|
||||
// Transform runs adapter.Fn(raw) and returns the resulting string.
|
||||
//
|
||||
// One retry on ErrPoolClosed: the pool was swapped by a concurrent rewrite
|
||||
// (plugin token rotation) between this lookup and the checkout. Re-reading the
|
||||
// registry gets the new pool, which is exactly what the caller wants.
|
||||
func (v *VM) Transform(name, fn, raw string) (string, error) {
|
||||
p := v.pool(name)
|
||||
if p == nil {
|
||||
return "", fmt.Errorf("adapter %s not loaded", name)
|
||||
}
|
||||
w, err := p.acquire()
|
||||
w, err := acquireAcrossSwap(v, name, p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -1072,10 +1150,16 @@ func inspectScript(name, code string) (*staticInfo, error) {
|
||||
L.Close()
|
||||
return nil, fmt.Errorf("adapter script must return a table")
|
||||
}
|
||||
// The registration key is the default identity; a script MAY declare its own
|
||||
// name, in which case the declared one wins. That case matters for plugins:
|
||||
// a plugin registers under a key it controls, but the adapter it installs
|
||||
// may declare the name sources actually reference — so the bridge reports
|
||||
// the effective name back instead of guessing.
|
||||
info := &staticInfo{name: name, headers: map[string]string{}}
|
||||
if n := readString(L, -1, "name"); n != "" {
|
||||
info.name = n
|
||||
}
|
||||
info.registeredAs = name
|
||||
info.version = readString(L, -1, "version")
|
||||
info.endpoint = readString(L, -1, "endpoint")
|
||||
info.endpointStream = readString(L, -1, "endpoint_stream")
|
||||
@ -1444,3 +1528,56 @@ func (v *VM) InstallPluginAdapter(name, code string) error {
|
||||
}
|
||||
return v.LoadAdapterSource(name, code)
|
||||
}
|
||||
|
||||
// AdapterVersion returns the version string an adapter declares, or "" when the
|
||||
// adapter is not loaded.
|
||||
//
|
||||
// It exists so an operator (or a plugin's own UI) can SEE that a runtime
|
||||
// rewrite happened: after a token rotation the version bumps, and /api/status
|
||||
// reports it. Without that, a rotation is invisible from outside the process.
|
||||
func (v *VM) AdapterVersion(name string) string {
|
||||
p := v.pool(name)
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.static.version
|
||||
}
|
||||
|
||||
// EffectiveAdapterName resolves the name an adapter is actually registered
|
||||
// under, given the key it was loaded with.
|
||||
//
|
||||
// A script may declare its own `name`, and the declared one wins at load time.
|
||||
// Callers that keep per-adapter state (the plugin ownership table, the
|
||||
// serializer cache) must key it on the EFFECTIVE name, or the state attaches to
|
||||
// a key nothing will ever look up again.
|
||||
func (v *VM) EffectiveAdapterName(registeredAs string) string {
|
||||
if p := v.pool(registeredAs); p != nil {
|
||||
p.mu.Lock()
|
||||
if p.static.name != "" {
|
||||
n := p.static.name
|
||||
p.mu.Unlock()
|
||||
return n
|
||||
}
|
||||
p.mu.Unlock()
|
||||
return registeredAs
|
||||
}
|
||||
// The script declared a name different from the key it was loaded under, so
|
||||
// the pool is filed under the DECLARED name. Find it that way, or every
|
||||
// caller would conclude "not loaded" and re-register a duplicate.
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
for _, p := range v.pools {
|
||||
if p.static.registeredAs == registeredAs {
|
||||
p.mu.Lock()
|
||||
n := p.static.name
|
||||
p.mu.Unlock()
|
||||
if n != "" {
|
||||
return n
|
||||
}
|
||||
return registeredAs
|
||||
}
|
||||
}
|
||||
return registeredAs
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user