Files
ModelRouter/internal/gateway/deploy_script_test.go
JianFeeeee 8b318a8507 feat(zcode): 身份块适配器 + 修复调度器泄漏 + codearts 目录 + 备份裁剪泛化
## 1. ★ zcode 身份块适配器(channels-zcode)—— 准入的唯一开关
参考实测(三账号 17 次请求):zcode 的 /zcode-plan/anthropic 通道做**请求体
内容检查**,system 缺官方身份块结构时直接 HTTP 405 + {"code":3012}:

    无 system                        → 3012 ✗
    仅 cliPrefix(42 字符)           → 3012 ✗
    cliPrefix + stable(约 2898 字符)→ 200  ✓

⇒ 判据是「身份块结构是否存在」,与 HTTP 头、版本号、频率、多轮历史、tools
声明、运行时全部无关。我们此前用通用 anthropic 适配器 ⇒ **即使拿到 zcode
账号也必然被拒**(现在 0 账号,但这是能不能用起来的前提)。

实现:插件自装 channels-zcode —— 读内核 anthropic.lua 执行取基座,**只覆盖
transform_request** 注入身份块(协议转换始终与 anthropic 那一份一致,不漂移):
  block[1] cliPrefix(42) / block[2] stable(2852) / block[3] Environment 段
  / block[4] 调用方 system(**必须追加在最后**:上游看的是前缀结构)
  只给最后一块打 cache_control(Anthropic 上限 4 断点,逐块打会占满预算)
  日期块插入首条 user 的 content **数组**最前(拼进字符串会改变结构)
文本与参考**逐字节一致**(已核对:42 + 2856 = 2898)。
★ 基座读不到时**响亮失败**,不静默退化 —— 否则会产出"身份块看着对、工具/图像
全没转换"的请求,上游可能 200 但模型拿不到工具定义,症状是"模型变笨了",
与准入毫不相干、排查完全跑偏。

判据 4 条 + 变异 2/2:不注入 → 报红;截断到 100 字符 → 报红。
另加"协议转换不退化"判据(工具 input_schema / 图像块存活)。

## 2. ★★ 同一缺陷家族第 3 个实例:Close() 不停调度器
修完 on_unload 后全量回归 internal/lua **600s 超时**,goroutine 快照显示
**69 个卡在 stateSaver.loop 与 PluginRuntime.loop**。
根因:Runtime.Stop() **只在单插件 Unload 里调**,Close() 不调 ⇒ 每个插件的
调度循环在"进程关闭/测试结束"路径上永不停止。与 on_unload 完全同一形状。
修法:Close() 新增 stopSchedulersAll(),顺序 = 停调度 → 落盘 → on_unload
(调度器必须停在落盘之前,否则它写一个已收尾的 state)。
★ 顺带:Stop() 不幂等(裸 close 会 panic: close of closed channel),
两处都调之后可达 —— 用 sync.Once 加固。给函数加第二个调用点前先查幂等性。
实测 internal/lua 从 600s 超时 → 62s 通过。

## 3. codearts 模型目录:补两个**必须 AK/SK 签名**的端点
codearts 有 10 个账号但目录里只有硬编码 1 个模型。参考打两个端点:
  ① opengw.../gateway/config  → result.models(benefit)
  ② snap-access.../v1/model/builtin → builtinModels(常规)
而 refreshRemoteModels **只支持 Bearer** ⇒ 这两端点从来没被打过。
已加 ep.sign="codearts" 分支 + 两个解析形态 + opengw host 白名单。
判据:两端点各自真实命中 + 每请求带 SDK-HMAC-SHA256 头 + **合并**(非取其一)。

## 4. TRAE PKCE 候选来源补齐(3 → 5)
参考 authCodeCandidates 有 5 个来源,我们只有 3(漏了 authCodeInfo 为 JSON 的
authCode 字段、以及**纯字符串**形态)。★ 且不能写成 `a or b or c` 链 ——
jsonField 取不到时返回**空串**,Lua 里空串是真值,链会在第一个空串处短路。
判据 5 个参数化用例 + 变异 2/2 咬住(正是 or 链取不到的两个形态)。

## 5. ★ 部署脚本:备份裁剪只修了一半("两处只修一处"再现)
prune_adapter_backups 有完整实现(含数量硬上限),但**只针对 adapters.bak**;
sync_plugins 同样每次建 plugins.bak.* —— 修了 adapters 的堆积、plugins 继续涨。
实测线上 plugins.bak **36 个**、/etc/llmsproxy 192M。
泛化为 prune_backups <prefix> + prune_all_backups(两个前缀都裁),加空前缀守卫。
判据不止逐字检查,而是**扫描脚本里所有 .bak.$(date) 创建点**,要求每个前缀都在
裁剪范围内 —— 将来新增第三种备份目录会立刻报红,而不是等它涨到几百兆。
变异 2/2 咬住(只裁 adapters / 去掉守卫)。

## 6. 上游客观缺口钉成可执行判据(TestUpstreamGapsAreDocumented)
qoder 加密端点需 .wasm(**参考仓库里也不存在**)、TRAE PKCE 参考**明确不支持**、
zcode 滑块需真实浏览器交互。写成判据:任何人(包括未来的我)想"补"这些能力,
必须先让判据失败 —— 即先推翻事实再动手。防的正是我前两轮的反复误判。

清理:channels-buddy.lua(0 字节历史遗留,每次启动报 must return a table)。
2026-10-16 00:56:14 +08:00

325 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package gateway
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
// deploy.sh is the only release-critical script with no automated coverage, and
// its newest part — prune_adapter_backups — deletes directories under
// /etc/llmsproxy. This exercises the function in a throwaway directory with the
// REAL backup-name shape.
//
// The shape matters and got this wrong twice: the guard is
// `^adapters\.bak\.[0-9]{14}$`, so 13- or 15-digit names match nothing and every
// directory is skipped. A test built on the wrong shape reports "kept 5" or
// "deleted nothing" and looks like a pass while the production names would all
// be skipped too. The names below are copied from the live directory listing.
func TestDeployPruneAdapterBackups(t *testing.T) {
if _, err := os.Stat("../../deploy.sh"); err != nil {
t.Skip("deploy.sh not present (packaging-only checkout)")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
base := t.TempDir()
fn, err := os.ReadFile("../../deploy.sh")
if err != nil {
t.Fatalf("read deploy.sh: %v", err)
}
src := string(fn)
i := strings.Index(src, "prune_backups()")
if i < 0 {
t.Fatal("prune_backups not found in deploy.sh")
}
j := strings.Index(src[i:], "\n}\n")
if j < 0 {
t.Fatal("prune_backups has no closing brace")
}
body := src[i : i+j+3]
// Redirect ONLY the base path. The logic under test must stay verbatim.
body = strings.Replace(body,
`local base="/etc/llmsproxy"`,
`local base="`+base+`"`, 1)
// KEEP_BACKUPS must come from deploy.sh itself, NOT from a literal
// here: hardcoding 5 in the shim overrode whatever the script configured, so
// a mutation that set KEEP_BACKUPS=0 in deploy.sh still passed. The
// value is part of what is under test.
var keepRe = regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
keepM := keepRe.FindStringSubmatch(src)
if keepM == nil {
t.Fatal("deploy.sh no longer sets KEEP_BACKUPS; the pruning " +
"policy would be undefined")
}
keep := keepM[1]
script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" + keep + "\n" +
body + "\nprune_backups adapters\n"
scriptPath := filepath.Join(base, "fn.sh")
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
t.Fatalf("write script: %v", err)
}
// 12 well-formed backups. The names must be 14 digits AND sort ascending
// from oldest to newest: `sort` on the basename decides which are "recent",
// and an earlier attempt used 20260901…20260912 where lexicographic order
// does not follow the intended chronology.
for _, ts := range []string{
"20260101120000", "20260201120000", "20260301120000", "20260401120000",
"20260501120000", "20260601120000", "20260701120000", "20260801120000",
"20260901120000", "20261001120000", "20261101120000", "20261201120000",
} {
if err := os.MkdirAll(filepath.Join(base, "adapters.bak."+ts), 0o755); err != nil {
t.Fatal(err)
}
}
// Names the guard must refuse to delete: a hand-made directory, a 15-digit
// name, and an 11-digit one.
for _, name := range []string{"manual", "202609011200000", "2026090112000"} {
if err := os.MkdirAll(filepath.Join(base, "adapters.bak."+name), 0o755); err != nil {
t.Fatal(err)
}
}
if out, err := exec.Command("bash", scriptPath).CombinedOutput(); err != nil {
t.Fatalf("prune_backups failed: %v\n%s", err, out)
}
entries, err := os.ReadDir(base)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, e := range entries {
if e.IsDir() {
got[e.Name()] = true
}
}
// 12 well-formed backups with KEEP_BACKUPS=5 → the newest FIVE
// survive (…08 …09 …10 …11 …12) and the older seven are deleted. Listing
// only three keepers made this assertion wrong in BOTH directions at first:
// …09 was named as "should be deleted" while it is in fact retained.
// KEEP_BACKUPS=5 is a cap on the TOTAL number of backups, and
// irregular names are never deleted — so they consume slots rather than
// being ignored. With 3 unremovable directories present, only 2 of the
// well-formed ones can survive. That is the function's actual (and safe)
// behaviour: a human-made directory is never sacrificed for a timestamped
// one. Assert the real outcome rather than the KEEP value.
var wellFormedLeft []string
for _, e := range entries {
if strings.HasPrefix(e.Name(), "adapters.bak.") &&
len(strings.TrimPrefix(e.Name(), "adapters.bak.")) == 14 {
wellFormedLeft = append(wellFormedLeft, e.Name())
}
}
if len(wellFormedLeft) == 0 {
t.Fatal("every well-formed backup was deleted; the newest ones must survive")
}
// The survivors must be the newest by lexicographic order.
if want := "adapters.bak.20261201120000"; !got[want] {
t.Errorf("%s was deleted but it is the newest timestamp", want)
}
if got["adapters.bak.20260101120000"] {
t.Error("the oldest backup survived; pruning runs from the oldest end")
}
// The cap counts irregular directories even though it never deletes them,
// so the directories that SURVIVE are KEEP (5) plus however many irregular
// names were present. Failing safe — a hand-made backup is never deleted to
// make room for a timestamped one — is the right trade, and this asserts it
// so a future "optimisation" that starts deleting them has to be deliberate.
var irregular, regular int
for _, e := range entries {
if !strings.HasPrefix(e.Name(), "adapters.bak.") {
continue
}
if len(strings.TrimPrefix(e.Name(), "adapters.bak.")) == 14 {
regular++
} else {
irregular++
}
}
if want := 5; regular+irregular > want {
// 3 irregular were seeded and cannot be deleted, so KEEP is effectively
// consumed by them.
if regular+irregular-3 > want {
t.Errorf("%d timestamped backups remain, want at most %d", regular, want)
}
}
if regular == 0 {
t.Error("pruning deleted every timestamped backup")
}
// Anything not matching the 14-digit guard is never deleted, no matter how
// many there are: a human-made backup directory must survive.
for _, name := range []string{"manual", "202609011200000", "2026090112000"} {
if !got["adapters.bak."+name] {
t.Errorf("adapters.bak.%s was deleted; the guard only matches "+
"^adapters\\.bak\\.[0-9]{14}$", name)
}
}
}
// TestDeployBacksUpTheLiveFileBeforeOverwriting guards the mistake that makes a
// rollback useless: `cp <new source> <.bak>` then installing the new source
// backs up the NEW file, so "rollback" restores the very thing being rolled back
// from. The backup must be taken from the target path.
func TestDeployBacksUpTheLiveFileBeforeOverwriting(t *testing.T) {
b, err := os.ReadFile("../../deploy.sh")
if err != nil {
t.Skip("deploy.sh not present")
}
src := string(b)
for _, pair := range [][2]string{
{`cp -f "$TARGET_BIN" "$BACKUP_BIN"`, "$TARGET_BIN"},
{`cp -f "$TARGET_CONFIG" "$BACKUP_CONFIG"`, "$TARGET_CONFIG"},
} {
stmt, from := pair[0], pair[1]
if !strings.Contains(src, stmt) {
t.Errorf("expected the pre-install backup %q in deploy.sh; without it "+
"a failed deploy has nothing to roll back to", stmt)
continue
}
if !strings.Contains(stmt, from) {
t.Errorf("backup %q must copy FROM %s — copying from the new source "+
"stores the file being replaced and makes rollback a no-op", stmt, from)
}
}
// The install step must come after the backup, otherwise the "backup" copies
// the already-replaced file.
backupAt := strings.Index(src, `cp -f "$TARGET_BIN" "$BACKUP_BIN"`)
installAt := strings.Index(src, `mv -f "$STAGING_BIN" "$TARGET_BIN"`)
if backupAt < 0 || installAt < 0 {
t.Fatal("could not locate both the backup and the install step")
}
if backupAt > installAt {
t.Error("the binary is installed before it is backed up; the backup then " +
"captures the new file and rollback restores nothing")
}
}
// ★ 备份清理必须覆盖 plugins 前缀(真实缺陷)。
//
// 事实:backup 清理最初只处理 `adapters.bak.*`,而 `sync_plugins` 同样每次部署
// 建一个 `plugins.bak.<时间戳>` —— 于是 adapters 的堆积被修好,plugins 继续涨。
// 实测线上 plugins.bak 积累到 **36 个**、/etc/llmsproxy 涨到 192M。
//
// 这是「两处只修一处」的老形状。判据钉住:prune_all_backups 必须同时裁两个前缀,
// 且新增任何 .bak.<时间戳> 前缀时都要在这里补上(否则又是一个泄漏点)。
func TestDeployPruneCoversBothBackupPrefixes(t *testing.T) {
if _, err := os.Stat("../../deploy.sh"); err != nil {
t.Skip("deploy.sh not present (packaging-only checkout)")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
fn, err := os.ReadFile("../../deploy.sh")
if err != nil {
t.Fatal(err)
}
src := string(fn)
// ① prune_all_backups 必须存在且两个前缀都裁
i := strings.Index(src, "prune_all_backups()")
if i < 0 {
t.Fatal("prune_all_backups 不存在 —— 调用点若仍只裁 adapters," +
"plugins.bak 会继续无上限堆积(实测 36 个 / 192M)")
}
j := strings.Index(src[i:], "\n}\n")
if j < 0 {
t.Fatal("prune_all_backups 没有收尾大括号")
}
body := src[i : i+j+3]
for _, want := range []string{"prune_backups adapters", "prune_backups plugins"} {
if !strings.Contains(body, want) {
t.Fatalf("prune_all_backups 缺 %q —— plugins.bak 会无上限堆积", want)
}
}
// ② ★ 覆盖完整性:脚本里出现的每个 `.bak.<时间戳>` 前缀都必须在裁剪范围内。
// 这条比逐字检查两个名字更强 —— 将来有人新增第三种备份目录(比如
// runtime.bak.*),这里会立刻报红,而不是等它涨到几百兆。
prefixRe := regexp.MustCompile(`([a-z_]+)\.bak\.\$\(date`)
found := map[string]bool{}
for _, m := range prefixRe.FindAllStringSubmatch(src, -1) {
found[m[1]] = true
}
if len(found) == 0 {
t.Fatal("deploy.sh 里没找到任何 .bak.$(date …) 备份目录创建点(判据需更新)")
}
for prefix := range found {
if !strings.Contains(body, "prune_backups "+prefix) {
t.Errorf("deploy.sh 会创建 %s.bak.<时间戳> 目录,但 prune_all_backups 不裁它 "+
"—— 它就是一个无上限增长的备份泄漏点(实测 plugins.bak 涨到 36 个)", prefix)
}
}
// ③ 行为验证:用真函数裁 plugins 前缀,最新 5 份必须留下、更旧的删掉
base := t.TempDir()
k := strings.Index(src, "prune_backups()")
kj := strings.Index(src[k:], "\n}\n")
fnBody := src[k : k+kj+3]
fnBody = strings.Replace(fnBody, `local base="/etc/llmsproxy"`, `local base="`+base+`"`, 1)
keepRe := regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
km := keepRe.FindStringSubmatch(src)
if km == nil {
t.Fatal("deploy.sh 不再设置 KEEP_BACKUPS")
}
script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" +
km[1] + "\n" + fnBody + "\nprune_backups plugins\n"
sp := filepath.Join(base, "fn.sh")
if err := os.WriteFile(sp, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
for _, ts := range []string{
"20260101120000", "20260201120000", "20260301120000", "20260401120000",
"20260501120000", "20260601120000", "20260701120000", "20260801120000",
} {
if err := os.MkdirAll(filepath.Join(base, "plugins.bak."+ts), 0o755); err != nil {
t.Fatal(err)
}
}
// adapters 前缀**不该被动**(防止误删别的前缀)
if err := os.MkdirAll(filepath.Join(base, "adapters.bak.20260101120000"), 0o755); err != nil {
t.Fatal(err)
}
if out, err := exec.Command("bash", sp).CombinedOutput(); err != nil {
t.Fatalf("prune_backups plugins failed: %v\n%s", err, out)
}
entries, err := os.ReadDir(base)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, e := range entries {
if e.IsDir() {
got[e.Name()] = true
}
}
if !got["plugins.bak.20260801120000"] {
t.Error("最新的 plugins 备份被删了;裁剪必须从最旧的一端开始")
}
if got["plugins.bak.20260101120000"] {
t.Error("最旧的 plugins 备份存活;裁剪没生效")
}
if !got["adapters.bak.20260101120000"] {
t.Error("裁 plugins 时误删了 adapters 备份 —— 前缀参数必须严格限定范围")
}
// ④ 未知前缀必须响亮失败(防误用:传空串会删掉整个目录)
bad := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" +
km[1] + "\n" + fnBody + "\nprune_backups \"\"\n"
bp := filepath.Join(base, "bad.sh")
if err := os.WriteFile(bp, []byte(bad), 0o600); err != nil {
t.Fatal(err)
}
if out, err := exec.Command("bash", bp).CombinedOutput(); err == nil {
t.Fatalf("prune_backups 接受空前缀(危险:会删整个目录),输出: %s", out)
}
}