test(deploy): 给 deploy.sh 的备份裁剪与回滚点补判据

发版前核验: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 只改到第一个匹配点、漏掉真正的删除点,
误报成「判据漏放」——是变异脚本没改到位。
This commit is contained in:
JianFeeeee
2026-10-02 15:44:50 +08:00
parent 400b6c35a5
commit 689c8cb383

View File

@ -0,0 +1,204 @@
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")
}
}