mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
发版前核验:deploy.sh 是唯一没有自动覆盖的发布关键脚本,而它新增的
prune_adapter_backups 会在 /etc/llmsproxy 下删目录。deploy.sh 本身路径硬编码
(会 mv 覆盖 /usr/local/bin/llmsproxy 与 config.yaml 并重启服务),不能在生产
试跑,所以把函数抽到临时目录实测。
两条判据:
- TestDeployPruneAdapterBackups:在 t.TempDir() 里造 12 个规范备份 + 3 个
不规则目录,抽取函数本体(只重定向 base,逻辑一字不动)执行,断言最旧被删、
最新保留、不规则目录绝不被删。
- TestDeployBacksUpTheLiveFileBeforeOverwriting:锁住「先备份线上文件再安装」
这条纪律,并断言备份语句出现在安装语句之前。
写判据时踩的三个坑(都是判据自身的错,不是被测代码的错):
1. 备份名位数。守卫是 ^[0-9]{14}$,第一版造了 13 位和 15 位的名字,全部被
「名字不规范」跳过,看起来像「什么都没删」的假通过。这和记忆里线上那次
15 位时间戳的坑是同一个。
2. KEEP_ADAPTER_BACKUPS=5 写在了 shim 里,等于覆盖被测脚本自己的配置——把
deploy.sh 里的 KEEP 改成 0 判据照样通过。改为从被测脚本正则读取该值。
3. 存活数期望写错。KEEP 计入不规则目录的数量却永不删除它们,所以实际存活
是 KEEP + 不规则目录数(实测 7)。这是「宁可多留也不删人工目录」的正确
取舍,判据改为断言这个语义。
变异验证 3/3 被捕获:去掉两处名字正则(误删人工目录)、KEEP 改 0、备份来源
换成新文件。第三个变异第一版用 sed 只改到第一个匹配点、漏掉真正的删除点,
误报成「判据漏放」——是变异脚本没改到位。
205 lines
7.7 KiB
Go
205 lines
7.7 KiB
Go
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_adapter_backups()")
|
|
if i < 0 {
|
|
t.Fatal("prune_adapter_backups not found in deploy.sh")
|
|
}
|
|
j := strings.Index(src[i:], "\n}\n")
|
|
if j < 0 {
|
|
t.Fatal("prune_adapter_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_ADAPTER_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_ADAPTER_BACKUPS=0 in deploy.sh still passed. The
|
|
// value is part of what is under test.
|
|
var keepRe = regexp.MustCompile(`(?m)^KEEP_ADAPTER_BACKUPS=(\d+)`)
|
|
keepM := keepRe.FindStringSubmatch(src)
|
|
if keepM == nil {
|
|
t.Fatal("deploy.sh no longer sets KEEP_ADAPTER_BACKUPS; the pruning " +
|
|
"policy would be undefined")
|
|
}
|
|
keep := keepM[1]
|
|
|
|
script := "warn() { :; }\nlog() { :; }\nKEEP_ADAPTER_BACKUPS=" + keep + "\n" +
|
|
body + "\nprune_adapter_backups\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_adapter_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_ADAPTER_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_ADAPTER_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")
|
|
}
|
|
}
|