Files
HomeAgent/internal/update/kernel_apply_test.go
JianFeeeee 86d1f4c547 test(update): 补 TestMarkPendingUpgrade_PreservesOldMarkerOnFailure(判据 B2c)
## 为什么还要再加一条

前两条 commit 之后,B2 的「原子写」在判据侧其实是哑的 —— 我复核时实测
过:把 MarkPendingUpgrade 的 `tmp := p + ".tmp"` 改成 `tmp := p`
(退回最初的原地覆写 bug),以及把整个函数退回 os.WriteFile,
`TestMarkPendingUpgrade_IsAtomic` **都照样绿**。

因为它断言的是「最终状态对」:不留 .tmp 残留、内容可解析、可覆写。
os.WriteFile 这三条全都满足。也就是说那条用例证明的是「结果正确」,
没证明「写的过程不会毁掉已有数据」—— 而后者才是 B2 要防的事故。

## 这条用例钉什么

用它逼出两者唯一可观测的差异:**失败路径上旧文件还在不在**。

  ① 先写一个完好的旧 marker(FromVer=1.0.0)
  ② 把 pendingPath+".tmp" 占成**目录** ⇒ OpenFile 必 EISDIR
     (稳定可复现,不依赖权限/磁盘/平台;失败点与「磁盘满/掉电」同类:
      已打开目标、还没写完全部内容)
  ③ MarkPendingUpgrade 必须返回 error(不能假装标记已就绪)
  ④ 旧 marker 必须原样保留:FromVer 仍为 1.0.0、ToVer 仍为 1.1.0

③④ 都用 Error 而非 Fatal:让两个症状一起报出来。

## 语义差异

	原子写(现状):新内容写进 .tmp 时失败 ⇒ 目标文件仍是完整旧版本
	原地覆写      :O_TRUNC 先清空再写,中途失败 ⇒ 目标文件被截断,
	                旧 marker 永久丢失 ⇒ 不知道上次升了什么,也没了回滚依据

## 变异(已实测)

	B2c-M1  rollback.go  `tmp := p + ".tmp"` → `tmp := p`   ⇒ RED
	        实际输出:旧 marker 被破坏了:FromVer 应为 1.0.0,实际 "2.0.0"

零用例假绿也堵住了:判据脚本对 `no tests to run` 直接 FATAL。

仅动 internal/update/kernel_apply_test.go(测试文件),生产代码零改动。
2026-10-28 17:56:44 +08:00

837 lines
33 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 update
// 内核 Apply 的四条高危缺陷回归测试(H1–H4)。
//
// 这四条的共同点:**失败时不是报错,而是静默产出"看起来成功了"的升级**。
// 而升级路径上的静默失败 = 机器进崩溃循环 + 没人知道为什么。所以每个用例
// 都刻意去断言"副作用没有发生",而不只是断言"返回了 error"。
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/events"
)
// ── 测试辅助 ────────────────────────────────────────────────
// fakePackageBin 造一个"能被 ReadBinVersion 读出版本"的假二进制。
//
// ReadBinVersion 会真的去 exec 它并解析 `key=value` 行,所以这里写一个
// 最小可执行的 shell 脚本:预检(H2 之后的备份路径检查)能照常通过。
func fakePackageBin(t *testing.T, version, commit string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "homed")
script := "#!/bin/sh\n" +
"echo version=" + version + "\n" +
"echo commit=" + commit + "\n" +
"echo build_time=2026-01-01T00:00:00Z\n" +
"echo sdk=1.4.0\n"
if err := os.WriteFile(p, []byte(script), 0755); err != nil {
t.Fatalf("写假二进制: %v", err)
}
return p
}
// newPkgRoot 造一个"解包后"的包目录(root 下含 bin/homed)。
//
// Apply 用 FindFile(root, "homed") 找新二进制,所以目录结构要一致。
func newPkgRoot(t *testing.T, version, commit string) string {
t.Helper()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "bin"), 0755); err != nil {
t.Fatal(err)
}
bin := fakePackageBin(t, version, commit)
if err := CopyFile(bin, filepath.Join(root, "bin", "homed")); err != nil {
t.Fatal(err)
}
return root
}
// installFakeKernel 在临时目录里造一个"现役内核"(bin/homed + depgraph.bin)。
func installFakeKernel(t *testing.T) (binPath, dgPath string) {
t.Helper()
dir := t.TempDir()
binPath = filepath.Join(dir, "homed")
if err := os.WriteFile(binPath, []byte("#!/bin/sh\necho version=1.4.0\necho commit=old\n"), 0755); err != nil {
t.Fatal(err)
}
dgPath = filepath.Join(dir, "depgraph.bin")
if err := os.WriteFile(dgPath, []byte("OLD-DEPGRAPH"), 0644); err != nil {
t.Fatal(err)
}
return binPath, dgPath
}
// collectResults 订阅 update_result,返回取事件的函数。
func collectResults(t *testing.T, bus *events.Bus) func() []*events.Event {
t.Helper()
var got []*events.Event
bus.Subscribe(events.EventUpdateResult, func(e *events.Event) {
got = append(got, e)
})
return func() []*events.Event { return got }
}
// ── H1:dataDir 为空必须拒绝升级 ────────────────────────────
// TestApplyRefusesEmptyDataDir 钉住"无标记就无回滚 ⇒ 不许升级"。
//
// ★ 断言的重点不是 error 存在,而是**磁盘上什么都没变**:
// 旧代码在 dataDir=="" 时只是跳过写 pending,后面照样替换二进制,
// 于是产出一个"升级成功、但新版本起不来时不会回滚"的状态 ——
// 这正是崩溃循环的成因。所以这里必须逐字节比对替换前后的内容。
func TestApplyRefusesEmptyDataDir(t *testing.T) {
binPath, dgPath := installFakeKernel(t)
root := newPkgRoot(t, "1.9.9", "newcommit")
origBin, err := os.ReadFile(binPath)
if err != nil {
t.Fatal(err)
}
origDG, err := os.ReadFile(dgPath)
if err != nil {
t.Fatal(err)
}
// 包内带 depgraph,确保"如果代码继续往下走"它确实会被替换 ——
// 否则这个用例测不出 depgraph 被改动。
newDG := filepath.Join(root, "bin", "depgraph.bin")
if err := os.WriteFile(newDG, []byte("NEW-DEPGRAPH"), 0644); err != nil {
t.Fatal(err)
}
// ★★★ 关键:在**临时目录**里跑,并拍下跑之前的快照。
//
// 为什么必须切 CWD:守卫万一被挪到后面(先预检/先备份才判空),
// filepath.Join("", prefix) 会得到一个**相对路径**,于是备份目录被
// 建到当前目录下。旧用例只查了"目标二进制没变",这种 "什么都没写"
// 的违规完全看不到(变异实测:把守卫挪到预检之后,旧用例依旧绿)。
workDir := t.TempDir()
oldWD, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(workDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chdir(oldWD) })
out, err := Apply(root, binPath, newDG, "", nil)
if err == nil {
t.Fatalf("dataDir 为空时必须拒绝升级,实际返回 out=%+v, err=nil", out)
}
if out != nil {
t.Errorf("拒绝升级时不应返回 Outcome,实际 %+v", out)
}
if !strings.Contains(err.Error(), "dataDir 为空") {
t.Errorf("错误文案应明确指出 dataDir 为空,实际: %v", err)
}
// ① 目标二进制**逐字节未变**
gotBin, _ := os.ReadFile(binPath)
if string(gotBin) != string(origBin) {
t.Errorf("拒绝升级却改动了二进制:\n 期望 %q\n 实际 %q", origBin, gotBin)
}
// ② depgraph 也未被替换(同批替换的另一半)
gotDG, _ := os.ReadFile(dgPath)
if string(gotDG) != string(origDG) {
t.Errorf("拒绝升级却改动了 depgraph.bin:\n 期望 %q\n 实际 %q", origDG, gotDG)
}
// ③ 连临时文件都没留下(CopyFile 的 .tmp-upgrade)
if FileExists(binPath + ".tmp-upgrade") {
t.Error("拒绝升级却留下了 .tmp-upgrade 临时文件")
}
// ④ ★ 根本没有写任何东西:备份目录/pending 标记都不许出现。
// "不写任何文件"是 H1 的原始承诺(拒绝要在**预检之前**发生)。
// 备份目录会因为 filepath.Join("", …) 而变成相对路径 —— 那种情况下
// 它会在 CWD 里,所以这里必须盯住 CWD。
entries, derr := os.ReadDir(workDir)
if derr != nil {
t.Fatal(derr)
}
if len(entries) != 0 {
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
t.Errorf("拒绝升级却写了文件(dataDir 为空时的守卫必须发生在动任何文件之前): %v", names)
}
}
// ── H2:备份目录必须落在 dataDir 下,不能是 /root ────────────
// TestBackupDirUnderDataDir 钉住备份落点。
//
// 旧代码写死 /root/homed-upgrade-backup-<ts>:服务以
// ProtectSystem=strict + ReadWritePaths=/var/lib/hologram 运行,
// 非 root 身份对 /root 没有写权限 ⇒ MkdirAll 必 EACCES ⇒ **首次升级必挂**。
//
// 所以本条既断言"前缀是 dataDir",也断言 dataDir 下真的出现了备份目录
// 且里面有两个文件。同时反证:/root 下不该被创建(当 dataDir != /root 时)。
func TestBackupDirUnderDataDir(t *testing.T) {
binPath, _ := installFakeKernel(t)
root := newPkgRoot(t, "1.9.9", "newcommit")
dataDir := t.TempDir()
out, err := Apply(root, binPath, "", dataDir, nil)
if err != nil {
t.Fatalf("Apply 应成功: %v", err)
}
if out == nil || out.BackupDir == "" {
t.Fatal("Outcome.BackupDir 不应为空")
}
// ① 路径前缀必须是 dataDir,而不是 /root
if !strings.HasPrefix(out.BackupDir, dataDir+string(os.PathSeparator)) {
t.Errorf("备份目录应在 dataDir 下: dataDir=%s, BackupDir=%s", dataDir, out.BackupDir)
}
if strings.HasPrefix(out.BackupDir, "/root") {
t.Errorf("备份目录不能落在 /root(服务无写权限,首次升级必挂): %s", out.BackupDir)
}
// ② 名字前缀不变(GC 按前缀识别,见 gc.go)
if !strings.HasPrefix(filepath.Base(out.BackupDir), BackupDirPrefix) {
t.Errorf("备份目录名应以 %q 开头,实际 %s", BackupDirPrefix, filepath.Base(out.BackupDir))
}
// ③ 备份真的写进去了(homed + depgraph.bin 都存在)
if !FileExists(filepath.Join(out.BackupDir, "homed")) {
t.Error("备份目录里应有 homed")
}
if !FileExists(filepath.Join(out.BackupDir, "depgraph.bin")) {
t.Error("备份目录里应有 depgraph.bin(与 homed 同批)")
}
// ④ pending 标记落在 dataDir 下(备份路径与标记同源,才能被回滚读到)
if !FileExists(pendingPath(dataDir)) {
t.Errorf("pending 标记应在 dataDir 下: %s", pendingPath(dataDir))
}
rec, _ := ReadPendingUpgrade(dataDir)
if rec == nil {
t.Fatal("应能读回 pending 标记")
}
if rec.BackupDir != out.BackupDir {
t.Errorf("标记里的 BackupDir 应指向本次备份: 标记=%s, Outcome=%s", rec.BackupDir, out.BackupDir)
}
}
// ── H3:备份目录要有 GC ────────────────────────────────────
// TestPruneOldBackups 钉住保留策略:upgrade 备 3 个,broken 备 1 个。
//
// ★ "剩下的是最新的"这一条同样重要:若 GC 删错方向(留下最旧的),
// 回滚源就会指向早已被替换过多次的版本,回滚等于装回一个更老的版本。
//
// 排序依据是目录名里的定长时间戳(NowStamp 的 20060102-150405),
// 所以这里不用 os.Chtimes 造 mtime,而是直接按名字造 —— 与实现约定一致,
// 也避免文件系统时间戳精度/时区差异让测试变成 flaky。
func TestPruneOldBackups(t *testing.T) {
dataDir := t.TempDir()
// 5 个备份(升序时间戳:第 i 个越新)
backupTS := []string{
"20260101-010101", "20260102-010101", "20260103-010101",
"20260104-010101", "20260105-010101",
}
for _, ts := range backupTS {
d := filepath.Join(dataDir, BackupDirPrefix+ts)
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "homed"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
// 2 个坏二进制现场
brokenTS := []string{"20260101-020202", "20260102-020202"}
for _, ts := range brokenTS {
if err := os.MkdirAll(BrokenDirPath(dataDir, ts), 0755); err != nil {
t.Fatal(err)
}
}
// 干扰项:不该被删(pending 标记)与不该被匹配(前缀相近但不是)
marker := pendingPath(dataDir)
if err := os.WriteFile(marker, []byte(`{"from_version":"1.4.0"}`), 0644); err != nil {
t.Fatal(err)
}
notOurs := filepath.Join(dataDir, BackupDirPrefix+"not-a-timestamp")
if err := os.MkdirAll(notOurs, 0755); err != nil {
t.Fatal(err)
}
// 一个普通文件(不是目录):GC 只处理目录
strayFile := filepath.Join(dataDir, BackupDirPrefix+"20260109-010101")
if err := os.WriteFile(strayFile, []byte("i am a file"), 0644); err != nil {
t.Fatal(err)
}
PruneOldBackups(dataDir)
// ① 备份剩 3 个,且是最新的三个
gotBackups := listStampDirs(t, dataDir, BackupDirPrefix)
wantBackups := []string{
BackupDirPrefix + "20260103-010101",
BackupDirPrefix + "20260104-010101",
BackupDirPrefix + "20260105-010101",
}
sort.Strings(gotBackups)
sort.Strings(wantBackups)
if len(gotBackups) != 3 {
t.Fatalf("应保留 3 个备份,实际 %d 个: %v", len(gotBackups), gotBackups)
}
for i := range wantBackups {
if gotBackups[i] != wantBackups[i] {
t.Errorf("保留的应是最新的 3 个备份\n 期望 %v\n 实际 %v", wantBackups, gotBackups)
break
}
}
// ② broken 剩 1 个,且是最新的那个
gotBroken := listStampDirs(t, dataDir, BrokenDirPrefix)
if len(gotBroken) != 1 {
t.Fatalf("应保留 1 个 homed-broken-*,实际 %d 个: %v", len(gotBroken), gotBroken)
}
if gotBroken[0] != BrokenDirPrefix+"20260102-020202" {
t.Errorf("保留的应是最新的 broken 目录,实际 %s", gotBroken[0])
}
// ③ 没被误删:pending 标记、前缀相近的目录、以及那个同名普通文件
if !FileExists(marker) {
t.Error("GC 不能删 pending 标记(删了就等于丢掉回滚能力)")
}
if !FileExists(notOurs) {
t.Error("GC 不能删名字前缀相近但不是我们自己造的目录")
}
if !FileExists(strayFile) {
t.Error("GC 只处理目录,不该删同名文件")
}
// ④ 幂等:再跑一次不应有变化
PruneOldBackups(dataDir)
if n := len(listStampDirs(t, dataDir, BackupDirPrefix)); n != 3 {
t.Errorf("GC 应幂等,第二次跑后剩余 %d 个", n)
}
// ⑤ dataDir 为空时**什么都不做**(尤其不能把空串当 CWD 去删)
if before := len(listStampDirs(t, dataDir, BackupDirPrefix)); before != 3 {
t.Fatalf("前置条件被破坏: %d", before)
}
PruneOldBackups("")
if after := len(listStampDirs(t, dataDir, BackupDirPrefix)); after != 3 {
t.Errorf("dataDir 为空时不应改动任何东西(%d → %d)", 3, after)
}
}
// listStampDirs 列出 dir 下名字为 "<prefix><ts>" 形态的子目录。
//
// ★ 必须按时间戳形态过滤,不能用"前缀匹配"代替:GC 本身也按形态
// 过滤(不删名字碰巧带同样前缀的东西),用前缀匹配的辅助函数会把
// 那些干扰项算进数量,测出来的就不是真实保留个数。
func listStampDirs(t *testing.T, dir, prefix string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("读 %s: %v", dir, err)
}
var out []string
for _, e := range entries {
if !e.IsDir() || !strings.HasPrefix(e.Name(), prefix) {
continue
}
if isBackupStamp(e.Name()[len(prefix):]) {
out = append(out, e.Name())
}
}
sort.Strings(out)
return out
}
// ── H4:结果事件必须带 error,成功也必须发 result ────────────
// TestPublishResultCarriesError 钉住"失败原因必须能被订阅方读到"。
//
// 旧代码把 errMsg 收进 `if errMsg != "" { _ = errMsg }` 直接丢掉,
// 于是订阅方只看到 ok=false,**看不到为什么失败** —— 而失败原因正是
// 用户唯一想知道的东西("磁盘满"和"二进制不兼容"要采取的动作完全不同)。
func TestPublishResultCarriesError(t *testing.T) {
bus := events.NewBus()
results := collectResults(t, bus)
publishResult(bus, "kernel", "", false, "1.4.0", "1.4.1", true, "替换 homed: 权限不足")
got := results()
if len(got) != 1 {
t.Fatalf("应发出 1 个 update_result 事件,实际 %d 个", len(got))
}
payload := got[0].Payload
if payload["ok"] != false {
t.Errorf("ok 应为 false,实际 %v", payload["ok"])
}
errVal, exists := payload["error"]
if !exists {
t.Fatal("payload 必须带 error 字段(旧代码在这里把它丢掉了)")
}
msg, _ := errVal.(string)
if strings.TrimSpace(msg) == "" {
t.Fatal("失败时 payload[error] 不能为空 —— 订阅方靠它决定怎么处置")
}
if !strings.Contains(msg, "权限不足") {
t.Errorf("payload[error] 应保留原始失败原因,实际 %q", msg)
}
if payload["rolled_back"] != true {
t.Errorf("rolled_back 应为 true,实际 %v", payload["rolled_back"])
}
}
// TestApplySuccessEmitsResult 钉住"成功路径也发 result"。
//
// 旧代码只有失败路径调 publishResult,成功路径什么都不发 ⇒ 订阅方只能靠
// "进度停在最后一步且没看到 failed"来猜成功,猜错的方向是"以为还在升级",
// 而紧接着进程就重启消失了 —— 用户永远等不到那个结果。
func TestApplySuccessEmitsResult(t *testing.T) {
binPath, _ := installFakeKernel(t)
root := newPkgRoot(t, "1.9.9", "newcommit")
dataDir := t.TempDir()
bus := events.NewBus()
results := collectResults(t, bus)
out, err := Apply(root, binPath, "", dataDir, bus)
if err != nil {
t.Fatalf("Apply 应成功: %v", err)
}
got := results()
if len(got) != 1 {
t.Fatalf("成功路径必须发出 1 个 update_result 事件,实际 %d 个", len(got))
}
payload := got[0].Payload
if payload["ok"] != true {
t.Errorf("ok 应为 true,实际 %v", payload["ok"])
}
if payload["rolled_back"] != false {
t.Errorf("成功时 rolled_back 应为 false,实际 %v", payload["rolled_back"])
}
if payload["to"] != out.To.Version {
t.Errorf("payload[to] 应为新版本 %s,实际 %v", out.To.Version, payload["to"])
}
// 成功时 error 字段存在且为空:订阅方无需做"字段有没有"的兼容分支
if v, exists := payload["error"]; exists {
if s, _ := v.(string); s != "" {
t.Errorf("成功时 error 应为空串,实际 %q", s)
}
}
}
// ── M6:Apply 必须**真的调用** PruneOldBackups(行为级,不是"函数能跑")──
// TestApplyPrunesOldBackups 钉住"Apply 成功路径真的执行了 GC"。
//
// ★ 这一条是补 M6 真空洞的。此前只有 TestPruneOldBackups,它直接调
// PruneOldBackups —— 把 Apply 里那一行 `PruneOldBackups(dataDir)` 整个删掉,
// 它照样绿。也就是说旧判据只证明了"函数能跑",**没证明 Apply 调了它**,
// 而"没人调用"恰恰是 H3 的原始缺陷形态(注释承诺了 GC,代码里没有)。
//
// 做法:预置超量备份(含一个很旧的),跑一次真实 Apply,断言旧的被清掉。
// 这条路只有 Apply 内部真调了 GC 才会绿。
func TestApplyPrunesOldBackups(t *testing.T) {
binPath, _ := installFakeKernel(t)
root := newPkgRoot(t, "1.9.9", "newcommit")
dataDir := t.TempDir()
// 预置 5 个"历史备份",最旧的那个应该在本次 Apply 后被 GC 掉
oldTS := []string{
"20200101-010101", "20200102-010101", "20200103-010101",
"20200104-010101", "20200105-010101",
}
for _, ts := range oldTS {
if err := os.MkdirAll(filepath.Join(dataDir, BackupDirPrefix+ts), 0755); err != nil {
t.Fatal(err)
}
}
if _, err := Apply(root, binPath, "", dataDir, nil); err != nil {
t.Fatalf("Apply 应成功: %v", err)
}
// 5 个旧的 + 本次新增 1 个 = 6 个;保留最近 3 个 ⇒ 最旧的 3 个必须没了。
remaining := listStampDirs(t, dataDir, BackupDirPrefix)
if len(remaining) != 3 {
t.Fatalf("Apply 后应只剩 3 个备份(GC 未被调用?),实际 %d 个: %v", len(remaining), remaining)
}
for _, ts := range oldTS[:3] {
if FileExists(filepath.Join(dataDir, BackupDirPrefix+ts)) {
t.Errorf("Apply 后最旧的备份 %s 应已被 GC 清除", ts)
}
}
}
// ── B1:回滚必须回读校验,校验不过不得清 marker ─────────────
// TestRollback_RefusesCorruptBackup 钉住"拷贝成功 ≠ 回滚成功"。
//
// ★ 这是已复现过的事故形态:备份是**零字节**文件时,CopyFile 会成功
// (字节确实搬完了),旧代码于是清掉 marker、返回 rolled=true, err=nil,
// 日志还打 "rolled back to 1.4.0"。而磁盘上躺着的是一个不可执行的
// 二进制 ⇒ 机器带坏二进制继续启动,且 marker 已清 ⇒ **失去第二次自愈机会**。
//
// 正确行为:回读 ReadBinVersion 失败 ⇒ 返回 error、rolled=false、
// **marker 必须还在**(下一次启动还能再试 / 人能介入)。
func TestRollback_RefusesCorruptBackup(t *testing.T) {
dir := t.TempDir()
// 当前跑的是 1.4.0,与标记的 ToVer(1.4.1) 不同 ⇒ 进入回滚分支
bin := writeVersionScript(t, dir, "homed", "1.4.0", "oldcommit")
orig, _ := os.ReadFile(bin)
backupDir := filepath.Join(dir, "backup")
if err := os.MkdirAll(backupDir, 0755); err != nil {
t.Fatal(err)
}
// 零字节的坏备份:CopyFile 会"成功"
if err := os.WriteFile(filepath.Join(backupDir, "homed"), []byte{}, 0755); err != nil {
t.Fatal(err)
}
if err := MarkPendingUpgrade(dir, PendingRecord{
FromVer: "1.4.0", FromCommit: "oldcommit",
ToVer: "1.4.1", ToCommit: "newcommit",
BackupDir: backupDir,
}); err != nil {
t.Fatal(err)
}
rolled, _, _, err := RollbackIfUnconfirmed(dir, bin)
if err == nil {
t.Fatal("★★ 拿不可执行的备份\"回滚成功\"了 —— 未做回读校验")
}
if rolled {
t.Error("回滚校验失败时 rolled 必须为 false(否则调用方以为自愈完成)")
}
// ★ marker 必须仍在:失败后要保住第二次自愈机会
if rec, _ := ReadPendingUpgrade(dir); rec == nil {
t.Error("★★ 回滚失败却清了 marker ⇒ 失去第二次自愈机会")
}
// 错误必须可执行:指出是回读失败 + 让人去换二进制
msg := err.Error()
if !strings.Contains(msg, "回读") && !strings.Contains(msg, "不可执行") {
t.Errorf("错误应说明回读校验失败,实际: %v", msg)
}
_ = orig
}
// TestRollback_RefusesVersionMismatch 钉住"版本非得是标记里的 FromVer"。
//
// 备份存在且可执行,但版本不是当初记录的 FromVer(备份与 marker 不匹配,
// 例如备份目录被别的升级覆盖过)。这种情况不算回滚成功 —— 装回去的是
// 一个谁也不认识的版本,而 marker 里承诺的是 FromVer。
func TestRollback_RefusesVersionMismatch(t *testing.T) {
dir := t.TempDir()
bin := writeVersionScript(t, dir, "homed", "1.4.0", "oldcommit")
backupDir := filepath.Join(dir, "backup")
if err := os.MkdirAll(backupDir, 0755); err != nil {
t.Fatal(err)
}
// 备份是**可执行**的,但版本是 1.3.0(≠ marker.FromVer 1.4.0)
src := writeVersionScript(t, t.TempDir(), "homed", "1.3.0", "weird")
if err := CopyFile(src, filepath.Join(backupDir, "homed")); err != nil {
t.Fatal(err)
}
if err := MarkPendingUpgrade(dir, PendingRecord{
FromVer: "1.4.0", FromCommit: "oldcommit",
ToVer: "1.4.1", ToCommit: "newcommit",
BackupDir: backupDir,
}); err != nil {
t.Fatal(err)
}
rolled, _, _, err := RollbackIfUnconfirmed(dir, bin)
if err == nil {
t.Fatal("版本与标记的 FromVer 不符时必须报错")
}
if rolled {
t.Error("版本不符时 rolled 必须为 false")
}
if !strings.Contains(err.Error(), "1.3.0") {
t.Errorf("错误应报出实际版本,实际: %v", err)
}
if rec, _ := ReadPendingUpgrade(dir); rec == nil {
t.Error("版本不符时 marker 必须保留")
}
}
// TestRollback_CorruptMarkerDoesNotBlockBoot 钉住 B2 的第二半:
// marker 损坏(掉电截断)**不得**让内核拒绝启动。
//
// ★ 已复现的事故形态:marker 写成 `{"from_version":"1.4.0","to_` 时,
// ReadPendingUpgrade 返回 error,旧代码原样上抛,mod.go `return rerr`
// ⇒ 内核拒启动,而且走不到回滚分支 ⇒ **既起不来也回滚不了**。
//
// 正确行为:返回 (false, "", "", nil) —— 当作"无待确认升级"继续启动,
// 同时把坏 marker 改名留存(不删,保留现场)。
func TestRollback_CorruptMarkerDoesNotBlockBoot(t *testing.T) {
dir := t.TempDir()
bin := writeVersionScript(t, dir, "homed", "1.4.0", "oldcommit")
p := pendingPath(dir)
bad := []byte(`{"from_version":"1.4.0","to_`)
if err := os.WriteFile(p, bad, 0644); err != nil {
t.Fatal(err)
}
rolled, from, to, err := RollbackIfUnconfirmed(dir, bin)
if err != nil {
t.Fatalf("★★ marker 损坏不得让内核拒启动(既起不来也回滚不了): %v", err)
}
if rolled {
t.Error("marker 读不懂时无法判定回滚目标,rolled 应为 false")
}
if from != "" || to != "" {
t.Errorf("无回滚时版本轨迹应为空,实际 %s → %s", from, to)
}
// 坏 marker 应被改名留存(保留现场),而不是原样留着(否则下次启动又报一次)
if _, err := os.Stat(p); err == nil {
t.Error("损坏的 marker 应被隔离改名,否则每次启动都会再撞一次")
}
entries, _ := os.ReadDir(dir)
foundQuarantine := false
for _, e := range entries {
if strings.Contains(e.Name(), ".corrupt-") {
foundQuarantine = true
}
}
if !foundQuarantine {
t.Error("损坏的 marker 应被改名留存(排查\"上次升级发生了什么\"的唯一线索)")
}
}
// TestMarkPendingUpgrade_IsAtomic 钉住 B2 的第一半:写 marker 不留半截。
//
// 掉电时最坏的结果必须是"完整的旧版"或"完整的新版",不能是半截 JSON。
// 判据:写完不留下 .tmp 残留,且内容可被完整解析。
// (真正的掉电无法在单测里模拟,这里钉住原子写带来的可观测后果。)
func TestMarkPendingUpgrade_IsAtomic(t *testing.T) {
dir := t.TempDir()
rec := PendingRecord{FromVer: "1.4.0", FromCommit: "a", ToVer: "1.4.1", ToCommit: "b"}
if err := MarkPendingUpgrade(dir, rec); err != nil {
t.Fatalf("写标记: %v", err)
}
// ① 不留 .tmp 残留(rename 成功)
if FileExists(pendingPath(dir) + ".tmp") {
t.Error("写完后不应残留 .tmp 临时文件")
}
// ② 内容完整可解析
got, err := ReadPendingUpgrade(dir)
if err != nil {
t.Fatalf("读回: %v", err)
}
if got == nil || got.FromVer != "1.4.0" || got.ToVer != "1.4.1" {
t.Errorf("读回内容不对: %+v", got)
}
// ③ 覆写已有 marker 也要成功(旧版 WriteFile 截断写,这里同样不能坏)
if err := MarkPendingUpgrade(dir, PendingRecord{FromVer: "2.0.0", ToVer: "2.1.0"}); err != nil {
t.Fatalf("覆写标记: %v", err)
}
got2, err := ReadPendingUpgrade(dir)
if err != nil {
t.Fatalf("覆写后读回: %v", err)
}
if got2.FromVer != "2.0.0" {
t.Errorf("覆写未生效: %+v", got2)
}
}
// TestMarkPendingUpgrade_PreservesOldMarkerOnFailure 钉住 B2 的第二半:
// **写失败时不得破坏已有的旧 marker**。
//
// ★ 为什么 IsAtomic 不够(这是实测出来的缺口):
// TestMarkPendingUpgrade_IsAtomic 断言的是「最终状态对」——不留 .tmp、
// 内容可解析、可覆写。把 MarkPendingUpgrade 整个退回 os.WriteFile
// 这三条**全都满足**,于是那条判据对「原子写」这个真 bug 是哑的。
//
// 真正能区分两者的可观测差异只有一个:**失败路径上旧文件还在不在**。
//
// · 原子写:新内容写到 .tmp 崩掉 ⇒ 目标文件仍是**完整旧版本**
// · 原地覆写(os.WriteFile / tmp := p):O_TRUNC 先清空再写,
// 中途失败 ⇒ 目标文件被**截断**,旧 marker 永久丢失
//
// 而丢掉旧 marker 的后果正是 B2 要防的那个事故:不知道上次升了什么,
// 也失去回滚依据。所以这里用「把 tmp 路径占成目录」逼 OpenFile 必失败
// (EISDIR,稳定可复现,不依赖权限/磁盘/平台),再断言旧 marker 原样还在。
//
// ★ 变异纪律:把 rollback.go 的 `tmp := p + ".tmp"` 改成 `tmp := p`
// (即退回原地覆写),本用例必红 —— 那时 OpenFile 打开的是 marker 本身,
// O_TRUNC 立刻把它清空,FromVer 变成新值。
func TestMarkPendingUpgrade_PreservesOldMarkerOnFailure(t *testing.T) {
dir := t.TempDir()
// ① 先写一个完好的旧 marker(模拟「上次升级已有待确认记录」)。
old := PendingRecord{FromVer: "1.0.0", FromCommit: "oldcommit", ToVer: "1.1.0", ToCommit: "newok"}
if err := MarkPendingUpgrade(dir, old); err != nil {
t.Fatalf("预置旧 marker: %v", err)
}
// ② 把临时文件路径占成目录 ⇒ OpenFile(O_CREATE|O_WRONLY) 必 EISDIR。
// 这样「写新 marker」一定在中途失败,且失败点与磁盘满/掉电同类
// (已打开目标、还没写完全部内容)。
tmpPath := pendingPath(dir) + ".tmp"
if err := os.Mkdir(tmpPath, 0755); err != nil {
t.Fatalf("占用 tmp 路径: %v", err)
}
// ③ 写新的 marker 必须**报错**(不能假装写成功)。
// 用 Error 而非 Fatal:让下面的 ④ 也跑完,把「旧 marker 被破坏」
// 这个真正的事故形态一并报出来(两个症状都属于同一个病)。
if err := MarkPendingUpgrade(dir, PendingRecord{FromVer: "2.0.0", ToVer: "2.1.0"}); err == nil {
t.Error("tmp 路径不可写时 MarkPendingUpgrade 必须返回 error(否则调用方以为标记已就绪)")
}
// ④ 旧 marker 必须**原样保留**(这是 B2 的核心承诺)。
got, rerr := ReadPendingUpgrade(dir)
if rerr != nil {
t.Fatalf("写失败后旧 marker 应仍可解析(不允许留下半截 JSON): %v", rerr)
}
if got == nil {
t.Fatal("写失败后旧 marker 丢了:没有回滚依据,且不知道上次升了什么")
}
if got.FromVer != "1.0.0" {
t.Errorf("旧 marker 被破坏了:FromVer 应为 1.0.0(旧值),实际 %q —— "+
"说明写新 marker 时原地覆写了目标文件(原子性丢失)", got.FromVer)
}
if got.ToVer != "1.1.0" {
t.Errorf("旧 marker 被破坏了:ToVer 应为 1.1.0(旧值),实际 %q", got.ToVer)
}
}
// ── 确认分支不得使用推断值(B1 的同一个病,转移到了 confirmed)──
// TestRollback_ConfirmedBranchIgnoresInferredVersion 钉住"确认新版已跑起来"
// 只能靠**binPath 自己**报版本,不能用推断回退借来的值。
//
// ★ 已实证的事故形态(环境相关,最难查的那类):
// - binPath 写成不可执行的残 file
// - marker.ToVer 恰等于本机可推断到的版本(如 /usr/local/bin/waiter 是 1.4.0)
// - ReadBinVersion 失败后走 InferVersionFromRunning 回退,从 waiter 借到
// 1.4.0 ⇒ 与 ToVer 相等 ⇒ confirmed=true ⇒ 清 marker、不回滚
// - 实测输出:rolled=false err=nil,marker 被清除
// ⇒ 机器带着跑不起来的二进制继续启动,且失去自愈机会。
//
// ★★ 判据自身必须**不依赖本机装没装 homed/waiter**,否则 CI 假绿、生产真红。
// 探针路径(inferWaiterPath/inferHomedPath)是包级变量,这里临时指到
// t.TempDir() 下自己造的假 waiter,跑完还原。这样判据在任何机器上都
// **确实**会走到"能借到版本"的成立条件,而不是靠环境碰运气:
// - confirmed 用 ReadBinVersion ⇒ 借到 9.9.9 ⇒ 判据 RED
// - confirmed 用 readBinVersionStrict ⇒ 借不到 ⇒ 走回滚 ⇒ 判据 GREEN
func TestRollback_ConfirmedBranchIgnoresInferredVersion(t *testing.T) {
// 把推断逻辑可读的两个探针都指到可写临时目录(并断言前提成立)。
savedHomed, savedWaiter := inferHomedPath, inferWaiterPath
t.Cleanup(func() { inferHomedPath, inferWaiterPath = savedHomed, savedWaiter })
probe := t.TempDir()
inferHomedPath = filepath.Join(probe, "homed") // 不存在:手段 1 必然失效
inferWaiterPath = filepath.Join(probe, "waiter") // 下面种一个能报版本的
body := "#!/bin/sh\nprintf 'waiter 9.9.9 (commit deadbeef, built 2026-01-01T00:00:00Z)\\n'\n"
if err := os.WriteFile(inferWaiterPath, []byte(body), 0755); err != nil {
t.Fatal(err)
}
// ① 前提校验:推断逻辑必须**真的**能从它借到版本。
// 借不到就说明这条判据无法区分两种实现(会假绿)—— 直接 Fatal 而不是 Skip:
// 现在探针路径是我们自己控制的,借不到就是判据自身坏了。
if v := InferVersionFromRunning(); v == nil || v.Version != "9.9.9" {
t.Fatalf("判据前提不成立:推断逻辑未从假 waiter 借到 9.9.9(得到 %+v)", v)
}
dir := t.TempDir()
bin := filepath.Join(dir, "homed")
// 不可执行的残 file:strict 读出版本必然失败
if err := os.WriteFile(bin, []byte("THIS-IS-NOT-A-BINARY"), 0755); err != nil {
t.Fatal(err)
}
// 可用的备份(版本 = FromVer),保证"走回滚"这条路能成功,
// 这样 confirmed 判错的后果只体现在"该不该回滚"上,不被别的失败掩盖。
backupDir := filepath.Join(dir, "backup")
if err := os.MkdirAll(backupDir, 0755); err != nil {
t.Fatal(err)
}
writeVersionScript(t, backupDir, "homed", "1.4.0", "oldcommit")
// marker.ToVer 恰好等于推断逻辑能借到的版本 9.9.9
if err := MarkPendingUpgrade(dir, PendingRecord{
FromVer: "1.4.0", FromCommit: "oldcommit",
ToVer: "9.9.9", ToCommit: "deadbeef",
BackupDir: backupDir,
}); err != nil {
t.Fatal(err)
}
rolled, from, to, err := RollbackIfUnconfirmed(dir, bin)
if err != nil {
t.Fatalf("备份可用时回滚不应失败: %v", err)
}
// ★ 核心断言:binPath 报不出版本 ⇒ 必须回滚,绝不能因为"借来的版本"而确认
if !rolled {
t.Fatalf("★★★ binPath 不可执行,却被判为\"新版已跑起来\"(confirmed 用了推断值)⇒ 机器带坏二进制继续启动")
}
if from != "1.4.0" || to != "9.9.9" {
t.Errorf("版本轨迹不对: %s → %s", from, to)
}
// ② 回读(严格)证明 binPath 真的被换成了可执行的 1.4.0
if v, verr := readBinVersionStrict(bin); verr != nil {
t.Errorf("回滚后 binPath 应可执行: %v", verr)
} else if v.Version != "1.4.0" {
t.Errorf("应回滚到 1.4.0,实际 %s", v.Version)
}
}
// TestConfirmedBranch_StrictProbeBitesOnUnparseable 是上一条的"无反依赖"版本:
// 直接把探针指向一个**报不出版本**的假 waiter,并断言 confirmed 仍为 false。
//
// 为什么还要单独一条:上一条依赖"能借到版本"这个前提成立。这一条不依赖
// 任何前提,专门钉住"探针都失效时也不能确认"。
func TestConfirmedBranch_StrictProbeBitesOnUnparseable(t *testing.T) {
savedHomed, savedWaiter := inferHomedPath, inferWaiterPath
t.Cleanup(func() { inferHomedPath, inferWaiterPath = savedHomed, savedWaiter })
probe := t.TempDir()
inferHomedPath = filepath.Join(probe, "homed")
inferWaiterPath = filepath.Join(probe, "waiter")
// 能跑但输出里没有语义化版本 ⇒ versionFromWaiter 返回 nil
if err := os.WriteFile(inferWaiterPath, []byte("#!/bin/sh\necho no-version-here\n"), 0755); err != nil {
t.Fatal(err)
}
if v := InferVersionFromRunning(); v != nil {
t.Fatalf("判据前提不成立:应推断不出,实际 %+v", v)
}
dir := t.TempDir()
bin := filepath.Join(dir, "homed")
if err := os.WriteFile(bin, []byte("THIS-IS-NOT-A-BINARY"), 0755); err != nil {
t.Fatal(err)
}
backupDir := filepath.Join(dir, "backup")
if err := os.MkdirAll(backupDir, 0755); err != nil {
t.Fatal(err)
}
writeVersionScript(t, backupDir, "homed", "1.4.0", "oldcommit")
if err := MarkPendingUpgrade(dir, PendingRecord{
FromVer: "1.4.0", FromCommit: "oldcommit",
ToVer: "1.4.0", ToCommit: "oldcommit", // ToVer 恰等于备份版本(最易误判的形态)
BackupDir: backupDir,
}); err != nil {
t.Fatal(err)
}
rolled, _, _, err := RollbackIfUnconfirmed(dir, bin)
if err != nil {
t.Fatalf("回滚不应失败: %v", err)
}
if !rolled {
t.Error("★★ binPath 不可执行时必须回滚(不能因 ToVer 与备份版本相同就确认)")
}
}