diff --git a/internal/update/events.go b/internal/update/events.go index 568d03e4..da098a39 100644 --- a/internal/update/events.go +++ b/internal/update/events.go @@ -42,6 +42,16 @@ func publishProgress(bus *events.Bus, scope, name, step, detail string) { } // publishResult 发布升级最终结果(成功或失败,含回滚轨迹)。 +// +// ★ error 字段**总是**出现在 payload 里(成功时为空串)。 +// +// 此前失败路径把 errMsg 收进一个 `if errMsg != "" { _ = errMsg }` 分支 +// 直接丢掉了 —— 于是订阅方只能看到 ok=false,**看不到失败原因**, +// 而"为什么失败"正是用户唯一想知道的。字段固定存在(即使为空)还让 +// 订阅方不必做"字段有没有"的兼容分支。 +// +// ★ 顺序约定:调用方必须在动作**之前**发事件(见文件头注释)。 +// 成功路径也一样:result 发出后调用方可能立即重启,进程随即消失。 func publishResult(bus *events.Bus, scope, name string, ok bool, from, to string, rolledBack bool, errMsg string) { if bus == nil { return @@ -56,12 +66,9 @@ func publishResult(bus *events.Bus, scope, name string, ok bool, from, to string "from": from, "to": to, "rolled_back": rolledBack, + "error": errMsg, }, }) - if errMsg != "" { - // 错误单独放,避免成功路径也带一个空 error 字段 - _ = errMsg - } } // publishCheck 发布一次更新检查结果(只读,不改动任何东西)。 diff --git a/internal/update/gc.go b/internal/update/gc.go new file mode 100644 index 00000000..c6aa338e --- /dev/null +++ b/internal/update/gc.go @@ -0,0 +1,143 @@ +package update + +// 备份目录的垃圾回收。 +// +// ## 为什么必须有 GC +// +// 每次 Apply 都在 dataDir 下留一个 homed-upgrade-backup-(homed + +// depgraph.bin,通常几十 MB),每次启动时回滚坏二进制还会多一个 +// homed-broken-。旧代码里 Outcome.BackupDir 的注释写着 +// "确认稳定后可删",但**全仓没有一行删除逻辑** —— 注释承诺了一个 +// 不存在的机制,而这类注释比没注释更糟:读代码的人会以为已经处理了。 +// +// 不删的后果不是"占点磁盘"这么温和:dataDir 和 pending 标记在同一个 +// 文件系统上,磁盘被备份吃满之后 pending 标记就写不进去 ⇒ Apply 拒绝 +// 升级(H1 的保护是对的,但保护频繁触发本身就是故障)。 +// +// ## 保留策略:upgrade 备 3 个,broken 备 1 个 +// +// 为什么不对称: +// +// · homed-upgrade-backup-* 是**回滚源**,可能被任意一次"上次升级的 +// pending 标记"引用。多留几个才能覆盖"升级→重启→再升级"的连环操作, +// 避免 GC 把当下那次升级还需要的那一份删掉。留 3 个够。 +// · homed-broken-* 是**事后现场**,只有人工排查时才看,且总是看最近 +// 一次。留 1 个(而非 0 个)是为了不丢现场。 +// +// 排序用目录名而不是 mtime:名字里的 ts 是 NowStamp() 的 +// "20060102-150405"(定长、字典序 == 时间序),不依赖文件系统时间戳的 +// 精度和时区,也不会被一次 cp -a 打乱。 + +import ( + "log" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + // BackupDirPrefix 是升级前备份目录名的前缀,需与 Apply 中拼接的一致。 + BackupDirPrefix = "homed-upgrade-backup-" + // BrokenDirPrefix 是回滚时留存的"坏二进制"目录名前缀。 + BrokenDirPrefix = "homed-broken-" + + // keepUpgradeBackups 保留的升级备份个数(回滚源,需要覆盖连环升级)。 + keepUpgradeBackups = 3 + // keepBrokenDirs 保留的坏二进制现场个数(只用于事后排查)。 + keepBrokenDirs = 1 +) + +// BrokenDirPath 返回某个时间戳的坏二进制留档目录(位于 dataDir 下)。 +// +// ★ 为什么不放在二进制目录旁:二进制目录通常是 /usr/local/bin, +// 在 ProtectSystem=strict 下只读,写不进去就等于**丢掉现场**。 +func BrokenDirPath(dataDir, ts string) string { + return filepath.Join(dataDir, BrokenDirPrefix+ts) +} + +// PruneOldBackups 清理 dataDir 下过量的备份目录。 +// +// 保留最近 keepUpgradeBackups 个 homed-upgrade-backup-* 与最近 +// keepBrokenDirs 个 homed-broken-*,其余 RemoveAll。 +// +// 设计上的克制(这几条都是为了"GC 本身不能造成故障"): +// +// · dataDir 为空 ⇒ 直接返回。空值曾被当作"当前目录",在这类函数里 +// 那就是灾难(会去删 CWD 下匹配前缀的东西)。 +// · 任何一次 RemoveAll 失败都只 log,不返回 error —— 调用点在 Apply +// 成功路径的末尾,GC 失败绝不能让一次**已经成功的升级**变成失败。 +// · 只处理目录、只处理名字精确匹配 "<前缀>" 形态的项(ts 必须是 +// NowStamp 的形状,见 isBackupStamp)—— 不递归、不碰 pending 标记、 +// 不碰名字碰巧带同样前缀的其它东西。GC 的删除权只能给"确实是自己 +// 造的东西"。 +func PruneOldBackups(dataDir string) { + if dataDir == "" { + return + } + pruneByPrefix(dataDir, BackupDirPrefix, keepUpgradeBackups) + pruneByPrefix(dataDir, BrokenDirPrefix, keepBrokenDirs) +} + +// isBackupStamp 判断 s 是否是 NowStamp() 的形状(20060102-150405)。 +// +// 只认定长数字+一个分隔短横:定长保证字典序 == 时间序,数字保证 +// 不会被任意名字混进来。 +func isBackupStamp(s string) bool { + if len(s) != len("20060102-150405") { + return false + } + for i := 0; i < len(s); i++ { + if i == 8 { + if s[i] != '-' { + return false + } + continue + } + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + +// pruneByPrefix 保留名字以 prefix 开头的最新 keep 个目录,其余删除。 +func pruneByPrefix(dir, prefix string, keep int) { + entries, err := os.ReadDir(dir) + if err != nil { + return // 目录还不存在(首次升级)就没什么可清的 + } + var names []string + for _, e := range entries { + if !e.IsDir() { + continue // 只删目录:磁盘满时备份里可能还有半截文件,按名字处理不到就跳过 + } + n := e.Name() + if !strings.HasPrefix(n, prefix) || len(n) == len(prefix) { + continue + } + // ★ 后缀必须是时间戳形态。 + // + // 只查前缀不查后缀的话,一个名字碰巧以该前缀开头的目录(如手工建的 + // homed-upgrade-backup-manual)会被当成备份,且因为 'm' > '9' 会排在 + // 时间戳**前面**当作"最新" ⇒ 它挤掉真正的备份、然后真正的最新备份 + // 反被删。GC 只应动它自己造的东西。 + if !isBackupStamp(n[len(prefix):]) { + continue + } + names = append(names, n) + } + if len(names) <= keep { + return + } + // 名字里的时间戳定长且字典序 == 时间序(见文件头),倒序即最新在前。 + sort.Sort(sort.Reverse(sort.StringSlice(names))) + for _, n := range names[keep:] { + p := filepath.Join(dir, n) + if err := os.RemoveAll(p); err != nil { + log.Printf("[update] prune %s failed: %v", p, err) + continue + } + log.Printf("[update] pruned old backup %s(保留最近 %d 个)", p, keep) + } +} diff --git a/internal/update/kernel.go b/internal/update/kernel.go index 93e3caa0..36e5b0d8 100644 --- a/internal/update/kernel.go +++ b/internal/update/kernel.go @@ -246,6 +246,34 @@ func FileSize(p string) int64 { return 0 } +// DataDirEnv 是部署方声明数据目录的环境变量名。 +const DataDirEnv = "HOMEAGENT_DATA_DIR" + +// ResolveDataDir 推导数据目录(升级确认标记、备份、坏二进制留档的落点)。 +// +// 三端(CLI / WebAPI / agent 工具)**必须**用同一份推导逻辑:pending 标记 +// 落在哪,第 0 层 update 模块就从哪读。两边一旦漂移,升级启动失败时就 +// **静默不回滚**,而症状(崩溃循环)与标记无关,极难反查。 +// +// 推导顺序: +// +// ① HOMEAGENT_DATA_DIR(部署方显式声明,唯一可靠的来源) +// ② /data 兜底(/usr/local/bin/homed → /usr/local/bin/data) +// +// ★ 兜底大概率**不是**内核 `--data` 的真实值,所以它只是"有个地方可写"的 +// 最后手段,不是正确解 —— 部署方应显式设置 HOMEAGENT_DATA_DIR。 +// 也**不**写死任何生产绝对路径(如 /home/newqqagent):目录/用户迁移后它 +// 指向一个不存在的目录,标记落错地方 ⇒ 静默不回滚(2026-10-26 迁移教训)。 +func ResolveDataDir(exePath string) string { + if d := strings.TrimSpace(os.Getenv(DataDirEnv)); d != "" { + return d + } + if exePath == "" { + exePath = KernelBinaryPath() + } + return filepath.Join(filepath.Dir(exePath), "data") +} + // Apply 执行真实升级:预检 → 备份 → 写 pending → 原子替换。 // // ## ★ 本函数**不重启**内核 @@ -267,6 +295,19 @@ func FileSize(p string) int64 { // 每一步失败都往回退:替换中途失败 ⇒ 立即用备份还原并清标记 // (此时尚未重启,回滚在本进程内完成,机器始终可启动)。 func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, error) { + // ── ★ 最前置的拒绝条件:没有 dataDir 就没有回滚能力,一律不升级 ── + // + // pending 标记、备份目录、坏二进制留档全都落在 dataDir 下。空 dataDir + // 意味着标记写不进去 ⇒ 新内核起不来时没有任何人知道该回滚。 + // 这种升级不是升级,是赌运气,赌输的代价是机器进崩溃循环。 + // + // 所以在**动任何文件之前**直接失败,把问题交给调用方去修环境 + // (设置 HOMEAGENT_DATA_DIR),而不是悄悄产出一个"看起来成功、 + // 实际没有回滚能力"的升级。 + if dataDir == "" { + return nil, fmt.Errorf("dataDir 为空,拒绝升级:无 pending 标记就无回滚能力") + } + newBin := FindFile(root, "homed") if newBin == "" { return nil, fmt.Errorf("包内无 homed") @@ -281,8 +322,11 @@ func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, err fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version) // ── 备份:只备本次会动的文件(不整目录拷贝,那是几百 MB)── + // ★ 备份必须落在 dataDir 下,不能是 /root: + // 服务以 ProtectSystem=strict + ReadWritePaths=/var/lib/hologram 运行, + // 非 root 身份对 /root 没有写权限 ⇒ MkdirAll 必 EACCES ⇒ 首次升级必挂。 ts := NowStamp() - backup := filepath.Join("/root", "homed-upgrade-backup-"+ts) + backup := filepath.Join(dataDir, BackupDirPrefix+ts) if err := os.MkdirAll(backup, 0755); err != nil { return nil, fmt.Errorf("建备份目录: %w", err) } @@ -306,29 +350,25 @@ func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, err if v, verr := ReadBinVersion(binPath); verr == nil { curVer = v } - if dataDir != "" { - if perr := MarkPendingUpgrade(dataDir, PendingRecord{ - FromVer: curVer.Version, - FromCommit: curVer.Commit, - ToVer: nv.Version, - ToCommit: nv.Commit, - BackupDir: backup, - }); perr != nil { - // 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力, - // 而不带回滚的升级等于赌运气。 - return nil, fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr) - } - fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir)) + if perr := MarkPendingUpgrade(dataDir, PendingRecord{ + FromVer: curVer.Version, + FromCommit: curVer.Commit, + ToVer: nv.Version, + ToCommit: nv.Commit, + BackupDir: backup, + }); perr != nil { + // 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力, + // 而不带回滚的升级等于赌运气。 + return nil, fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr) } + fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir)) // restore 在替换失败时把已动的文件还原(尚未重启,机器仍可启动)。 restore := func(cause error) (*Outcome, error) { publishProgress(bus, "kernel", "", "rollback", cause.Error()) fmt.Printf("\n❌ %v\n== 就地还原(尚未重启,机器仍可启动)==\n", cause) - if dataDir != "" { - if cerr := ClearPendingUpgrade(dataDir); cerr != nil { - fmt.Printf(" ⚠️ 清除升级确认标记失败: %v\n", cerr) - } + if cerr := ClearPendingUpgrade(dataDir); cerr != nil { + fmt.Printf(" ⚠️ 清除升级确认标记失败: %v\n", cerr) } for _, t := range targets { src := filepath.Join(backup, filepath.Base(t)) @@ -356,6 +396,17 @@ func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, err fmt.Println("已替换 homed") publishProgress(bus, "kernel", "", "ready", "新内核已就位,等待重启") + // ★ 成功路径也必须发 result 事件。 + // + // 此前只有失败路径(restore)发 result,成功路径什么都不发 ⇒ 订阅方 + // 只能靠"进度停在最后一步且没看到 failed"来猜成功,猜错的方向是 + // "以为还在升级"。先发事件再返回:返回后调用方可能立刻重启,进程消失。 + publishResult(bus, "kernel", "", true, curVer.Version, nv.Version, false, "") + + // 备份必须 GC:否则每次升级都在 dataDir 下留几十 MB,旧注释写 + // "确认稳定后可删",但全仓从来没有一行删除逻辑。 + PruneOldBackups(dataDir) + return &Outcome{ From: curVer, To: nv, diff --git a/internal/update/kernel_apply_test.go b/internal/update/kernel_apply_test.go new file mode 100644 index 00000000..abeed703 --- /dev/null +++ b/internal/update/kernel_apply_test.go @@ -0,0 +1,395 @@ +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) + } + + 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 临时文件") + } +} + +// ── H2:备份目录必须落在 dataDir 下,不能是 /root ──────────── + +// TestBackupDirUnderDataDir 钉住备份落点。 +// +// 旧代码写死 /root/homed-upgrade-backup-:服务以 +// 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 下名字为 "" 形态的子目录。 +// +// ★ 必须按时间戳形态过滤,不能用"前缀匹配"代替: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) + } + } +} diff --git a/internal/update/rollback.go b/internal/update/rollback.go index 9fe100cd..9b4e16b7 100644 --- a/internal/update/rollback.go +++ b/internal/update/rollback.go @@ -170,11 +170,16 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error } // 先备份"当前这个坏的"——万一回滚目标也有问题,至少能回到现场 + // + // ★ 落在 dataDir 下,不是二进制目录:二进制目录通常是 /usr/local/bin, + // 只读(ProtectSystem=strict)⇒ MkdirAll 静默失败(旧代码没查 err), + // 现场丢失且日志里只有一条无关的 copy 失败。 ts := NowStamp() - brokenDir := filepath.Join(filepath.Dir(binPath), "homed-broken-"+ts) - os.MkdirAll(brokenDir, 0755) - if err := CopyFile(binPath, filepath.Join(brokenDir, "homed")); err != nil { + brokenDir := BrokenDirPath(dataDir, ts) + if err := os.MkdirAll(brokenDir, 0755); err != nil { // 备份失败不阻断回滚:回滚是主线,留现场是支线 + log.Printf("[update] create broken-binary dir %s failed: %v", brokenDir, err) + } else if err := CopyFile(binPath, filepath.Join(brokenDir, "homed")); err != nil { log.Printf("[update] backup of broken binary failed: %v", err) } else { log.Printf("[update] broken binary kept at %s", brokenDir)