mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 12:17:23 +00:00
feat(channels): reap_orphan_sources —— 清理 v2.0.28 事故留下的存量孤儿源
## 发现路径:查"功能都正常了吗",逐源核对时发现的 生产 `config.yaml` 里有 21 个源,其中 `channels-buddy-585b335b64cf` 的账号 `585b335b64cf` **早已删除**(v2.0.23/24 的无凭据账号清理), 但它的源**仍在对外服务**: - 独占提供 `hy4-preview-x` ⇒ 实测报 `404 Route Not Found`; - 对 **6 个共享模型**则排在新源**前面**先被尝试(config 文件序即注册序) ⇒ 每次都撞 404 再回退,**每次白付一次上游往返**(实测 hy4-preview 稳定 3.1~3.5s); - 它的 base_url 还是 v2.0.30 之前的旧口径 (`https://copilot.tencent.com/v2/chat/completions`,侵占了 endpoint 路径)。 **根因**:这正是 v2.0.28(`remove_source` 漏声明 ⇒ 撤源全被拒)日记里 那句"账号删了、它的源还在对外服务"的**存量残骸**。 v2.0.28 修了声明,**只防新增、不清理存量**。 **发现的一个助力**:v2.0.38 刚修好的错误文案让它暴露得更清楚 —— 客户端拿到的是 `404 Route Not Found`(APISIX 原文)而不是 `unknown error`。 ## 修法 1. **立即清理**:走内核正式 API `DELETE /api/sources/<name>` 删掉该孤儿源 (不手工改配置)。清理前备份配置,三重确认它确无对应账号。 2. **结构性修复**:新增 `reap_orphan_sources` 正式入口 (`POST channels/reap_orphan_sources`,admin),识别 `channels-<已知平台>-<账号id>` 且账号**不在**池中的源。 ### 安全性(破坏性操作两条纪律,照 `reap_loopbacks` 先例) - **只删该删的**:按**已知平台 id 前缀**逐个匹配。 ⚠️ **不能用** `name:match("^channels%-(.+)-(.+)$")` —— Lua 的 `-` 是**贪婪**的, 会把 `channels-buddy-buddy-main` 切成 plat="buddy-buddy"/acct="main" (账号 id 含 `-` 就错,而 `buddy-main` 就含)⇒ **孤儿漏杀**。 运维手工配的源、旧版单账号源 `channels-<平台>` 一律不碰。 - **`dry_run` 默认开启**:不带 `dry_run=false` 只报告不删。 ## 判据(`channels_orphan_source_test.go`,变异 2/2) - 造出**真实形态**:`account_add` 建账号 → hidden 探针摘池(保留源), 模拟"源留下"这个状态(不走 `account_delete`:那条路径会把源一并撤掉, 而我们要的恰恰是它留下的形态)。 - 断言三件事:dry_run 只报告不删、真删后孤儿消失、**手工源全程存活**。 - **判别性用例**:第二个孤儿的账号 id 含 `-`(`dead-acct-1`)。 这是唯一能区分"按已知平台前缀匹配"与"贪婪 match"的用例 —— 实测不含 `-` 的 id 两种实现结果相同,**咬不住**(第一版就栽在这)。 - 变异:① `dry_run` 默认改 false ⇒ 报红;② 改回贪婪 match ⇒ 报红。 ## ★ 过程中踩到的三个坑(都已记进代码注释) 1. **`Plugins.Call` 的参数是平铺的,不是 `req.body`** —— `bodyOf(req)` 返回空表, `dry_run=false` 被静默丢成默认 true ⇒ "真删"永远没发生。 必须同时兜住两种调用形态(HTTP 路由走 body,插件 Call 平铺)。 2. **内核 `add_source` 取 `args["source"]`(嵌套一层)**,平铺字段被拒 ("add_source requires a source table")。 3. **`config.Model` 要求 models 是对象数组 `{id=...}`**,传字符串数组被拒。 4. **判据只查 Go 侧 `err` 会静默通过**:内核把失败放在**返回体**里, Go `err` 仍是 nil ⇒ "源根本没注册"看起来像"没有孤儿"。
This commit is contained in:
169
internal/core/channels_orphan_source_test.go
Normal file
169
internal/core/channels_orphan_source_test.go
Normal file
@ -0,0 +1,169 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ★ 孤儿源必须能被识别、且**只删该删的**。
|
||||
//
|
||||
// 真实事故(2026-10-17 生产实测):`channels-buddy-585b335b64cf` 的账号早已删除,
|
||||
// 源却仍在 config.yaml 里对外服务 —— 独占服务 hy4-preview-x(404),
|
||||
// 对 6 个共享模型则排在正常源**前面**先被尝试(每次白付 ~3.4s 上游往返)。
|
||||
//
|
||||
// 这是 v2.0.28(remove_source 漏声明)的**存量残骸**:修声明只防新增,不清理存量。
|
||||
func TestChannelsReapOrphanSources(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatalf("加载插件: %v", err)
|
||||
}
|
||||
// 生产里 channels 被授予这些(见 plugin.kernel 声明);夹具必须同样授权,
|
||||
// 否则 add_source 会被拒 ⇒ 造不出孤儿源。
|
||||
if err := ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "remove_source", "list_sources", "status"}, false); err != nil {
|
||||
t.Fatalf("授权: %v", err)
|
||||
}
|
||||
|
||||
// 装载前先清空账号池:这样插件初始化流程自己注册的源也一并算孤儿。
|
||||
cleanup := func() interface{} {
|
||||
r, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{
|
||||
"dry_run": false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reap_orphan_sources: %v", err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
cleanup()
|
||||
|
||||
// 造出**真实形态**:走 account_add 登记账号(它会注册源),
|
||||
// 再用 hidden 探针把账号从池里摘掉 —— 模拟 v2.0.28 事故的残留形态
|
||||
// (账号已删、撤源被拒 ⇒ 源留下继续对外服务)。
|
||||
// 不走 account_delete:那条路径的收尾已被 TestAccountDelete* 覆盖,
|
||||
// 而我们要的恰恰是"源留下了"这个状态。
|
||||
if r, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "deadbeef1234", "credential": "test-cred",
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
t.Fatalf("account_add 报错: %v", j["error"])
|
||||
}
|
||||
// ★ 第二个孤儿:账号 id **含 `-`**。这个用例是判别性的 ——
|
||||
// 用 `name:match("^channels%-(.+)-(.+)$")` 解析会被 Lua 的**贪婪** `-`
|
||||
// 切成 plat="buddy-dead"/acct="acct"(未知平台)⇒ 源被跳过 ⇒ **孤儿漏杀**。
|
||||
// (实测:只有这个用例能把"按已知平台前缀匹配"与"贪婪匹配"区分开;
|
||||
// 不含 `-` 的 id 两种实现结果相同,咬不住。)
|
||||
if r, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": "dead-acct-1", "credential": "test-cred",
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add(2): %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
t.Fatalf("account_add(2) 报错: %v", j["error"])
|
||||
}
|
||||
// 运维手工源:名字与账号源形态无关 ⇒ 绝不碰
|
||||
if r, err := ps.Call("channels", "__add_source_for_test", map[string]interface{}{
|
||||
"name": "my-handmade-source", "base_url": "http://127.0.0.1:1",
|
||||
"adapter": "openai", "models": []interface{}{"m1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("注册手工源: %v", err)
|
||||
} else if j := jsonOf(t, r); j["error"] != nil {
|
||||
// ★ 必须看 json 里的 error:内核把失败放在返回体里,
|
||||
// Go 侧 err 仍是 nil ⇒ 只查 err 会让"源根本没注册"静默通过。
|
||||
t.Fatalf("注册手工源报错: %v", j["error"])
|
||||
}
|
||||
// 摘账号(保留源)
|
||||
for _, id := range []string{"deadbeef1234", "dead-acct-1"} {
|
||||
if _, err := ps.Call("channels", "__forget_account_probe", map[string]interface{}{
|
||||
"platform": "buddy", "account_id": id,
|
||||
}); err != nil {
|
||||
t.Fatalf("摘账号 %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ① dry_run(默认)只报告、不删
|
||||
res, err := ps.Call("channels", "reap_orphan_sources", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := jsonOf(t, res)
|
||||
orphans, _ := j["orphans"].([]interface{})
|
||||
seen := map[string]bool{}
|
||||
for _, o := range orphans {
|
||||
if s, _ := o.(string); s != "" {
|
||||
seen[s] = true
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} {
|
||||
if !seen[want] {
|
||||
t.Fatalf("★ 没识别出孤儿源 %s(dry_run 应报告它)\n"+
|
||||
" 若 id 含 `-` 的那个漏了 ⇒ 说明用了贪婪 match 解析名字,应改为按已知平台前缀匹配\n"+
|
||||
" 实得 %v", want, seen)
|
||||
}
|
||||
}
|
||||
if seen["my-handmade-source"] {
|
||||
t.Fatalf("★ 误伤运维手工源(名字不匹配账号源形态,绝不能碰)")
|
||||
}
|
||||
if j["dry_run"] != true {
|
||||
t.Fatalf("dry_run 必须默认开启(破坏性操作不得默认生效),实得 %v", j["dry_run"])
|
||||
}
|
||||
if j["removed"].(float64) != 0 {
|
||||
t.Fatalf("dry_run=true 时不得真删,实得 removed=%v", j["removed"])
|
||||
}
|
||||
|
||||
// ② 确认 dry_run 真的没删(看源列表)
|
||||
after, err := ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := sourceNames(jsonOf(t, after))
|
||||
if !names["channels-buddy-deadbeef1234"] {
|
||||
t.Fatalf("dry_run 竟真删了!源列表: %v", names)
|
||||
}
|
||||
// 手工源必须存活(名字不匹配账号源形态 ⇒ 不该被识别为孤儿)
|
||||
if !names["my-handmade-source"] {
|
||||
t.Fatalf("dry_run 时就丢了手工源?源列表: %v", names)
|
||||
}
|
||||
|
||||
// ③ 真删:孤儿消失、手工源存活
|
||||
res, err = ps.Call("channels", "reap_orphan_sources", map[string]interface{}{"dry_run": false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j = jsonOf(t, res)
|
||||
if j["ok"] != true {
|
||||
t.Fatalf("清理应成功,实得 %v", j)
|
||||
}
|
||||
after, err = ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names = sourceNames(jsonOf(t, after))
|
||||
for _, dead := range []string{"channels-buddy-deadbeef1234", "channels-buddy-dead-acct-1"} {
|
||||
if names[dead] {
|
||||
t.Fatalf("★ 孤儿源 %s 未被清掉(它会继续对外服务无凭据的请求): %v", dead, names)
|
||||
}
|
||||
}
|
||||
if !names["my-handmade-source"] {
|
||||
t.Fatalf("★ 误删了运维手工源: %v", names)
|
||||
}
|
||||
t.Logf("✓ 孤儿源已清、手工源存活")
|
||||
}
|
||||
|
||||
// sourceNames 把 __list_sources_probe 的返回摊平成 name→true。
|
||||
func sourceNames(j map[string]interface{}) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if arr, ok := j["sources"].([]interface{}); ok {
|
||||
for _, x := range arr {
|
||||
if m, ok := x.(map[string]interface{}); ok {
|
||||
if s, ok := m["name"].(string); ok {
|
||||
out[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user