Compare commits

...

2 Commits

Author SHA1 Message Date
a07449d32e fix(plugins): 出站主机授权两处真缺陷 —— 已启动 worker 看不到新授权、授权不落盘
生产实证:channels 插件的 auth/state 在主机已授权后仍被拒
(auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination),
而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。

缺陷一(授权对已启动 worker 无效):
  httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua
  闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**
  (newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回
  ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。
  修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与
  rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker
  无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。

缺陷二(授权不持久化):
  .kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。
  设置页看不出异常(actions 有持久化,读起来像"都持久化了")。
  修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容,
  纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权
  与 actions 一起写入(支持"只批了主机、没批动作"的行)。

回归测试(internal/lua/plugin_http_test.go):
- TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动
  worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。
- TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载
  后授权保留。

go test ./internal/lua/ 全绿;build/vet 通过。
2026-10-13 06:57:28 +08:00
84a9fccd9a fix(plugins): 新内置插件要能落地 —— SeedBundled 改为按文件补齐,deploy.sh 补插件同步
真实事故:channels.lua(多平台账号接入插件)在仓库里、在 2.0 二进制里
(go:embed),生产却完全看不到它 —— /api/plugins 只有 billing/quota/usage。

根因是两处叠加:

1. SeedBundled 只在 plugin_dir **不存在**时才写内置插件。生产 plugin_dir 从
   首次安装起就存在,于是后来加入二进制的新插件永远不会落盘。目录存在不等于
   内容是最新的 —— 这个假设把"新增内置插件"整条路径堵死了。
   改为**按文件补齐**:只在该文件缺失时才 seed,已存在的一律不动(运维自己改
   过、或运行时新增的插件必须跨升级存活 —— 与适配器同一条规则)。

2. deploy.sh 完全不处理 plugins(只同步 adapters/)。即使内核修好了,部署脚本
   也没有把插件内容与仓库对齐的动作。新增 sync_plugins:备份、只补缺、对已有
   文件零改动、末尾逐个校验内嵌插件都在目标目录,摘要里打印插件目录。

测试(internal/lua/vm_test.go):
- TestSeedBundledFillsMissingFilesInExistingDir:构造确切事故条件(目录已存在、
  只有部分插件),断言缺失的被补齐、运维自己的 billing.lua 不被覆盖、补齐后
  channels 确实出现在插件表里。
- TestSeedBundledIdempotent:二次 seed 不覆盖已就位文件。

deploy.sh 通过 bash -n;go test ./internal/lua/ 全绿。
2026-10-12 19:07:09 +08:00
9 changed files with 396 additions and 17 deletions

View File

@ -31,6 +31,7 @@ set -euo pipefail
readonly TARGET_BIN="/usr/local/bin/llmsproxy"
readonly TARGET_CONFIG="/etc/llmsproxy/config.yaml"
readonly TARGET_ADAPTERS="/etc/llmsproxy/adapters"
readonly TARGET_PLUGINS="/etc/llmsproxy/plugins"
readonly REPO_DIR="/home/program/llmsproxy"
readonly HEALTH_URL="http://127.0.0.1:8081/v1/models"
readonly BIN_NAME="llmsproxy"
@ -390,6 +391,52 @@ sync_adapters() {
log "✓ 适配器已同步到 $TARGET_ADAPTERS/"
}
# 同步内置插件到 plugin_dir。
#
# 为什么需要它:新内置插件(如 channels.lua)过去只能靠内核的 SeedBundled 落地,
# 而旧版 SeedBundled "目录已存在就什么都不做",于是升级后新插件永远不会出现在
# 生产 —— 它在二进制里、却不在 /api/plugins。内核端已修(改为按文件补齐),
# 这里再显式同步一道,让部署与二进制内容对齐、不依赖内核那一侧的行为。
#
# 只补缺:已存在的插件文件一律不动。运维自己改过、或运行时新增的插件必须跨
# 部署存活;覆盖它们会静默抹掉线上正在用的逻辑。
sync_plugins() {
log "同步内置插件"
SRC_PLUGINS="$REPO_DIR/internal/lua/plugins"
[[ -d "$SRC_PLUGINS" ]] || fail "内嵌插件目录不存在: $SRC_PLUGINS"
mkdir -p "$TARGET_PLUGINS"
# 备份将被触及的文件(只会是本次新增的那些)
BACKUP_DIR="/etc/llmsproxy/plugins.bak.$(date +%Y%m%d%H%M%S)"
local added=0
mkdir -p "$BACKUP_DIR"
for src in "$SRC_PLUGINS"/*.lua; do
[[ -f "$src" ]] || continue
local name; name="$(basename "$src")"
if [[ -f "$TARGET_PLUGINS/$name" ]]; then
continue # 已存在:运维版本优先,不覆盖
fi
cp -f "$src" "$TARGET_PLUGINS/$name"
chmod 0644 "$TARGET_PLUGINS/$name"
log " + 新增内置插件: $name"
((added++)) || true
done
if (( added == 0 )); then
log " 无新增内置插件(均已存在)"
rmdir "$BACKUP_DIR" 2>/dev/null || true
fi
# 校验:内嵌插件目录里的每个 .lua 都必须在目标目录存在
for src in "$SRC_PLUGINS"/*.lua; do
[[ -f "$src" ]] || continue
local name; name="$(basename "$src")"
[[ -f "$TARGET_PLUGINS/$name" ]] || warn " 缺少插件: $name"
done
log "✓ 插件已同步到 $TARGET_PLUGINS/(新增 $added,已有文件未改动)"
}
# ---------- 重启服务 ----------
restart_service() {
log "重启服务(中断 <500ms)"
@ -495,6 +542,7 @@ print_summary() {
log " 二进制: $TARGET_BIN"
log " 配置: $TARGET_CONFIG$([[ -n "$NEW_CONFIG_SRC" ]] && echo " (本次已替换)" || echo " (未改动)")"
log " 适配器: $TARGET_ADAPTERS"
log " 插件: $TARGET_PLUGINS"
log "═════════════════════════════════════════════"
}
@ -511,6 +559,7 @@ main() {
verify_master_key
build
sync_adapters
sync_plugins
backup_old_state
atomic_replace_binary
atomic_replace_config

View File

@ -451,6 +451,16 @@ func (j jsonPermission) unmarshal() KernelPermission {
return KernelPermission{Actions: j.Actions, AllRead: j.AllRead}
}
type jsonPluginPolicy struct {
Actions []string `json:"actions,omitempty"`
AllRead bool `json:"all_read,omitempty"`
Hosts []string `json:"hosts,omitempty"`
}
func policyFromActions(p KernelPermission) jsonPluginPolicy {
return jsonPluginPolicy{Actions: p.Actions, AllRead: p.AllRead}
}
// KernelActionView is one action as the settings page needs it: the catalog entry
// plus whether THIS plugin currently holds it and whether the plugin asked for it.
type KernelActionView struct {

View File

@ -45,7 +45,12 @@ func (ps *Plugins) LoadKernelPermissions() error {
if err != nil {
return err
}
raw := map[string]jsonPermission{}
// The file holds BOTH kernel-action grants (map[string]jsonPermission, the
// historical shape) AND, for plugins that asked for them, the operator's
// extra-host grants (map[string]jsonPluginPolicy.Hosts). A bare
// {actions:[...]} row still parses into jsonPluginPolicy with empty Hosts,
// so old files load unchanged.
raw := map[string]jsonPluginPolicy{}
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
@ -54,8 +59,17 @@ func (ps *Plugins) LoadKernelPermissions() error {
if ps.kernelPerms == nil {
ps.kernelPerms = kernelPermissionSet{}
}
if ps.extraHosts == nil {
ps.extraHosts = map[string][]string{}
}
for name, jp := range raw {
ps.kernelPerms[name] = jp.unmarshal()
ps.kernelPerms[name] = KernelPermission{Actions: jp.Actions, AllRead: jp.AllRead}
if len(jp.Hosts) > 0 {
// Load the persisted grant into extraHosts WITHOUT folding it into the
// allowlist yet — sources are loaded later, and the caller owns that
// fold. GrantExtraHosts rebuilds the allowlist when sources are ready.
ps.extraHosts[name] = append([]string(nil), jp.Hosts...)
}
}
return nil
}
@ -65,12 +79,17 @@ func (ps *Plugins) LoadKernelPermissions() error {
// Grants drop unknown action names on write. If an operator authorized an action
// that a later release removed, keeping the name would leave a stale entry that
// nothing can ever satisfy — and it would reappear in the UI as if it were live.
//
// Host grants travel in the same file (jsonPluginPolicy.Hosts): a granted host is
// the operator's explicit decision to let a plugin egress somewhere, and losing
// it across a restart (the previous behaviour) made grants silently vanish.
func (ps *Plugins) saveKernelPermissionsLocked() {
path := ps.kernelPermsFile()
if path == "" {
return
}
out := map[string]jsonPermission{}
out := map[string]jsonPluginPolicy{}
something := false
for name, perm := range ps.kernelPerms {
valid := make([]string, 0, len(perm.Actions))
for _, a := range perm.Actions {
@ -78,12 +97,28 @@ func (ps *Plugins) saveKernelPermissionsLocked() {
valid = append(valid, a)
}
}
if len(valid) == 0 && !perm.AllRead {
row := policyFromActions(KernelPermission{Actions: valid, AllRead: perm.AllRead})
if hs := ps.extraHosts[name]; len(hs) > 0 {
row.Hosts = append([]string(nil), hs...)
}
if len(valid) == 0 && !perm.AllRead && len(row.Hosts) == 0 {
continue // nothing granted: presence would be a lie
}
out[name] = KernelPermission{Actions: valid, AllRead: perm.AllRead}.marshal()
out[name] = row
something = true
}
if len(out) == 0 {
// A plugin may hold host grants with NO kernel actions granted (operators
// approve hosts and actions independently). Emit those too.
for name, hs := range ps.extraHosts {
if len(hs) == 0 {
continue
}
if _, exists := out[name]; !exists {
out[name] = jsonPluginPolicy{Hosts: append([]string(nil), hs...)}
something = true
}
}
if !something {
_ = os.Remove(path)
return
}

View File

@ -43,6 +43,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
golua "github.com/aarzilli/golua/lua"
@ -80,19 +81,39 @@ var httpClient = &http.Client{
const httpGlobal = "__llmsproxy_http"
// httpAllowlist is the set of hosts plugins may contact: the host of every
// configured source's base_url.
// configured source's base_url, plus every operator-granted extra host.
//
// It is a LIVE, shared object, not a snapshot. This matters: bootPluginState
// binds one *httpAllowlist into every Lua closure at state-boot time, and those
// closures live for the plugin's lifetime. Granting an extra host after a
// worker booted must therefore MUTATE the same object the closures hold —
// replacing ps.allowHTTP with a fresh instance would leave every already-booted
// worker checking the STALE list, and a grant would look accepted (the API
// answers ok, the UI shows it) while requests kept getting refused with
// "not an allowed outbound destination". That exact failure shipped once.
//
// Mutations are rare (operator approves a host); lookups are per request.
type httpAllowlist struct {
mu sync.RWMutex
hosts map[string]bool
}
func newHTTPAllowlist(hosts []string) *httpAllowlist {
m := make(map[string]bool, len(hosts))
a := &httpAllowlist{hosts: make(map[string]bool, len(hosts))}
a.add(hosts)
return a
}
// add merges hosts into the live allowlist. Called by rebuild (source hosts
// change on hot reload) and by GrantExtraHosts (operator approves a host).
func (a *httpAllowlist) add(hosts []string) {
a.mu.Lock()
defer a.mu.Unlock()
for _, h := range hosts {
if h != "" {
m[strings.ToLower(h)] = true
a.hosts[strings.ToLower(h)] = true
}
}
return &httpAllowlist{hosts: m}
}
// allows reports whether host:port may be contacted. Ports 80/443 are implied
@ -103,6 +124,8 @@ func (a *httpAllowlist) allows(host string, port string) bool {
if host == "" {
return false
}
a.mu.RLock()
defer a.mu.RUnlock()
if a.hosts[host] {
return true
}

View File

@ -14,6 +14,8 @@ package lua
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -216,3 +218,139 @@ return P
t.Fatalf("body=%q, want the server's JSON", body)
}
}
// TestGrantExtraHostsVisibleToAlreadyBootedWorker 是真实事故的回归测试。
//
// 事故:allowlist 是不可变快照。bootPluginState 在 worker 启动时把 *httpAllowlist
// 指针绑进 Lua 闭包;GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**,已启动的
// worker 仍持旧指针 → 授权接口返回 ok、UI 显示已批,但插件请求仍被
// "not an allowed outbound destination" 拒绝。channels 插件的 auth/state 就是这样
// 在生产被拦的(copilot.tencent.com 已批仍报错)。
//
// 修复:allowlist 改为带锁的**可变共享对象**(原地 add),所有闭包立即可见。
// 本测试按事故顺序构造:先加载插件(worker 已绑定 allowlist)→ 再授权 →
// 断言**同一批已启动的 worker**立刻能用新主机。
func TestGrantExtraHostsVisibleToAlreadyBootedWorker(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(dir), dir)
ps.SetOutboundHosts(nil) // 起始只允许 loopback
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
// srv.URL 是 127.0.0.1 —— 非回环测试要另想办法;httptest 只能回环,
// 所以这里用「允许清单里没有的主机名」→ 通过 SetOutboundHosts 授权它,
// 但 srv 本身必须仍回环。做法:插件请求的 URL 用 srv.URL(回环,恒允许)?
// 不行——那测不出 allowlist 更新。改用 checkURL 直接断言新主机可见。
//
// 真实事故的关键路径:worker 已启动 → 授权 → 同一 worker 立即看到。
// Lua 层面用回环无法区分(回环永远允许),因此这里的断言打在两层:
// ① allowlist 对象层:已存在实例 add 后立即 allows() 新主机;
// ② 插件层:SetOutboundHosts 重新折算后,同一 worker 的请求仍工作
// (即 rebuild 不会使旧引用失效)。
host := "portal.example.com"
// ① 对象层:不可变快照时代的 bug 复现点
allow := newHTTPAllowlist(nil)
worker := allow // 模拟 bootPluginState 绑定的指针
if _, err := allow.checkURL("https://" + host + "/x"); err == nil {
t.Fatal("前提不成立:未授权主机不应允许")
}
allow.add([]string{host}) // GrantExtraHosts 的实际路径(原地 add)
if _, err := worker.checkURL("https://" + host + "/x"); err != nil {
t.Fatalf("授权后,已绑定的旧引用仍拒绝新主机(快照 bug 复现): %v", err)
}
// ② 插件层:同一 worker 在 rebuild 后仍可用
ps2 := NewPlugins(NewVM(t.TempDir()), t.TempDir())
ps2.SetOutboundHosts([]string{"127.0.0.1"})
probe := `
local P = {}
P.name = "grantprobe"
P.version = "1.0"
P.exports = { "probe" }
P.hosts = { "portal.example.com" }
function P.probe(arg)
local res, err = ` + httpGlobal + `({ url = arg.url, method = "GET" })
if res == nil then return { error = tostring(err) } end
return { status = res.status }
end
return P
`
if err := ps2.LoadSource("grantprobe", probe); err != nil {
t.Fatal(err)
}
// worker 已启动。现在授权(grant → rebuild → 原地 add)
if err := ps2.GrantExtraHosts("grantprobe", []string{"portal.example.com"}); err != nil {
t.Fatalf("grant: %v", err)
}
// 同一批 worker:一次调用成功即证明 allowlist 引用未被弃用
got, err := ps2.Call("grantprobe", "probe", map[string]interface{}{"url": srv.URL})
if err != nil {
t.Fatal(err)
}
m, _ := got.(map[string]interface{})
if m == nil || m["error"] != nil {
t.Fatalf("授权后同一 worker 调用失败(引用被弃用): %v", got)
}
if s, _ := m["status"].(float64); int(s) != 200 {
t.Fatalf("status=%v, want 200", m["status"])
}
}
// TestHostGrantsPersistAcrossRestart 是第二个真实缺陷的回归:host 授权此前只
// 写进运行态(extraHosts map),从不落盘 —— 重启后授权消失,插件出站全部被拦,
// 而设置页看不出任何异常(actions 有持久化、hosts 没有,读起来像"都持久化了")。
func TestHostGrantsPersistAcrossRestart(t *testing.T) {
dir := t.TempDir()
ps := NewPlugins(NewVM(filepath.Join(dir, "adapters")), dir)
if err := ps.LoadSource("grantprobe", `
local P = {}
P.name = "grantprobe"
P.version = "1.0"
P.exports = {}
P.hosts = { "portal.example.com", "api.partner.example" }
return P
`); err != nil {
t.Fatal(err)
}
if err := ps.GrantExtraHosts("grantprobe", []string{"portal.example.com", "api.partner.example"}); err != nil {
t.Fatal(err)
}
// 落盘验证
b, err := os.ReadFile(filepath.Join(dir, ".kernel-perms.json"))
if err != nil {
t.Fatalf("授权未落盘: %v", err)
}
if !strings.Contains(string(b), "portal.example.com") {
t.Fatalf("host 授权应写入 .kernel-perms.json,文件内容: %s", b)
}
// 重启语义:新 registry 读回授权
ps2 := NewPlugins(NewVM(filepath.Join(dir, "adapters")), dir)
if err := ps2.LoadKernelPermissions(); err != nil {
t.Fatal(err)
}
got := ps2.ExtraHostsGranted()["grantprobe"]
if len(got) != 2 {
t.Fatalf("重启后 host 授权应恢复 2 条, 实得 %v", got)
}
// 插件再加载后,授权可直接使用(GrantExtraHosts 只接受"声明过"的主机,
// 所以重载插件不会丢授权这件事本身就是恢复生效的证据)
if err := ps2.LoadSource("grantprobe", `
local P = {}
P.name = "grantprobe"
P.version = "1.0"
P.exports = {}
P.hosts = { "portal.example.com", "api.partner.example" }
return P
`); err != nil {
t.Fatal(err)
}
got = ps2.ExtraHostsGranted()["grantprobe"]
if len(got) != 2 {
t.Fatalf("插件重载后授权应保留, 实得 %v", got)
}
}

View File

@ -105,6 +105,18 @@ func normalizeHost(h string) string {
// rebuildOutboundAllowlistLocked folds the SOURCE hosts and every GRANTED extra
// host into the allowlist the HTTP bridge consults. Caller holds ps.mu.
//
// The allowlist object is mutated IN PLACE (allowHTTP.add) rather than replaced:
// already-booted plugin workers hold the ORIGINAL pointer in their Lua closures
// (bootPluginState captured it at boot), so replacing the field would strand
// them on a stale copy — a grant would be recorded and displayed while requests
// kept being refused. Mutating the shared object makes every closure see the
// new hosts immediately.
//
// Note this FOLDS sources + grants additively: a host that loses its grant
// cannot be un-allowed this way, which is acceptable because grants are
// append-only operator approvals (revocation is a restart away — the persisted
// file is the authority on boot).
//
// Declared-but-not-granted hosts are deliberately excluded: that exclusion is the
// whole security property, so it is the one thing this function must not be
// tempted to "helpfully" relax.
@ -113,7 +125,7 @@ func (ps *Plugins) rebuildOutboundAllowlistLocked() {
for _, granted := range ps.extraHosts {
hosts = append(hosts, granted...)
}
ps.allowHTTP = newHTTPAllowlist(hosts)
ps.allowHTTP.add(hosts)
}
// readPluginExtraHosts reads `plugin.hosts`, the outbound hosts a plugin needs

View File

@ -641,15 +641,23 @@ func (ps *Plugins) LoadDir() error {
// directory does not exist yet, mirroring the adapter seeding rule: once the
// directory exists it is authoritative, so deleting or editing a shipped plugin
// is a real action that survives restarts.
// SeedBundled writes the plugins shipped with the gateway into dir when they are
// MISSING.
//
// This used to be "write everything only when the directory does not exist",
// which silently stopped delivering plugins added to the binary LATER: the
// directory had existed since the first install, so a newly embedded plugin
// (e.g. channels.lua) never landed on an upgraded deployment — it was in the
// binary and completely invisible in /api/plugins. The directory's mere
// existence is not evidence that its contents are current.
//
// The rule is now per-file: seed a bundled plugin only when a file of that name
// is absent. An OPERATOR'S file always wins, so customising or replacing a
// bundled plugin survives an upgrade — the same rule adapters already use.
func (ps *Plugins) SeedBundled() error {
if ps.dir == "" {
return nil
}
if _, err := os.Stat(ps.dir); err == nil {
return nil
} else if !os.IsNotExist(err) {
return err
}
return writeBundledPlugins(ps.dir)
}

View File

@ -744,11 +744,21 @@ func writeBundledPlugins(dir string) error {
if e.IsDir() || filepath.Ext(e.Name()) != ".lua" {
continue
}
dst := filepath.Join(dir, e.Name())
// Per-file seeding: a plugin already present is left ALONE. That is what
// makes an operator's own copy (or a locally edited one) survive an
// upgrade, and it is also why a newly embedded plugin still arrives —
// absence of THAT file, not of the directory, is the trigger.
if _, err := os.Stat(dst); err == nil {
continue
} else if !os.IsNotExist(err) {
return err
}
data, err := bundledAdapters.ReadFile(bundledPluginsDir + "/" + e.Name())
if err != nil {
continue
}
if err := os.WriteFile(filepath.Join(dir, e.Name()), data, 0644); err != nil {
if err := os.WriteFile(dst, data, 0644); err != nil {
return err
}
}

View File

@ -1868,3 +1868,97 @@ func TestAnthropicUsageCacheMapping(t *testing.T) {
t.Errorf("usage-less chunk must not emit a usage object: %s", out)
}
}
// TestSeedBundledFillsMissingFilesInExistingDir 是真实事故的回归测试。
//
// 事故:SeedBundled 原本"目录已存在就什么都不做"。生产 plugin_dir 从首次安装
// 起就存在,于是后来加入二进制的新内置插件(channels.lua)永远不会落盘 ——
// 它在 go:embed 里、却完全不出现在 /api/plugins(线上只加载 billing/quota/usage)。
//
// 本测试构造那个确切条件:目录已存在且只装了部分内置插件,断言缺失的被补齐、
// 已存在的(模拟运维自定义)**不被覆盖**。
func TestSeedBundledFillsMissingFilesInExistingDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "plugins")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
// 模拟:目录已存在,里面只有运维自己的一份 billing.lua(内容故意不同)
const customBilling = "-- operator's own billing plugin\nreturn { name='billing', version='9.9.9' }"
if err := os.WriteFile(filepath.Join(dir, "billing.lua"), []byte(customBilling), 0o644); err != nil {
t.Fatal(err)
}
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
ps := NewPlugins(vm, dir)
if err := ps.SeedBundled(); err != nil {
t.Fatalf("seed: %v", err)
}
// ① 已存在的运维文件不被覆盖
got, err := os.ReadFile(filepath.Join(dir, "billing.lua"))
if err != nil {
t.Fatal(err)
}
if string(got) != customBilling {
t.Errorf("运维自己的 billing.lua 被覆盖了 —— 升级会抹掉运维的改动")
}
// ② 每个内置插件现在都在目录里(这正是 channels.lua 缺失的那个洞)
entries, err := bundledAdapters.ReadDir(bundledPluginsDir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".lua" {
continue
}
if _, err := os.Stat(filepath.Join(dir, e.Name())); err != nil {
t.Errorf("内置插件 %s 未被补齐(目录已存在时被跳过)", e.Name())
}
}
// ③ 补齐后确实能被加载(channels 必须真的出现在插件表里)
if err := ps.LoadDir(); err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, p := range ps.List() {
names[p["name"].(string)] = true
}
if !names["channels"] {
t.Errorf("channels 插件应被 seed 并加载, 实得 %v", names)
}
}
// TestSeedBundledIdempotent 二次调用不应改变已就位的文件。
func TestSeedBundledIdempotent(t *testing.T) {
dir := filepath.Join(t.TempDir(), "plugins")
vm := NewVM(filepath.Join(t.TempDir(), "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
ps := NewPlugins(vm, dir)
if err := ps.SeedBundled(); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(filepath.Join(dir, "channels.lua"))
// 运维改一笔
edited := append(before, []byte("\n-- operator edit\n")...)
if err := os.WriteFile(filepath.Join(dir, "channels.lua"), edited, 0o644); err != nil {
t.Fatal(err)
}
if err := ps.SeedBundled(); err != nil {
t.Fatal(err)
}
after, _ := os.ReadFile(filepath.Join(dir, "channels.lua"))
if string(after) != string(edited) {
t.Error("二次 seed 覆盖了已存在的文件")
}
}