From ecde8dd5159e13b64ae5932cd486bec7b551b19c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 28 Oct 2026 17:20:35 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(update):=20=E8=A1=A5=E4=B8=8A=E5=86=85?= =?UTF-8?q?=E6=A0=B8=E5=8D=87=E7=BA=A7=E7=BC=BA=E5=A4=B1=E7=9A=84=E5=9B=9B?= =?UTF-8?q?=E9=81=93=E5=AE=89=E5=85=A8=E9=97=B8=EF=BC=88H1=E2=80=93H4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四条缺陷的共同点都是「静默产出一个看起来成功的升级」,而失败的表现 是机器进崩溃循环且没人知道原因。 H1 dataDir 为空必须拒绝升级 Apply 此前只在 dataDir 非空时写 pending,空值仅跳过标记、后照常替换 二进制 ⇒ 产出「无回滚能力」的升级。现改为在动任何文件之前直接失败。 另新增导出 update.ResolveDataDir(env → /data 兜底)。 H2 备份目录不能写 /root backup 原为 /root/homed-upgrade-backup-,服务在 ProtectSystem=strict + ReadWritePaths=/var/lib/hologram 下运行,非 root 对 /root 无写权限 ⇒ MkdirAll 必 EACCES ⇒ 首次升级必挂。改为落在 dataDir 下。 rollback 的 homed-broken- 同样从(通常只读的)二进制目录挪到 dataDir。 H3 备份目录要有 GC 旧注释写「确认稳定后可删」,但全仓无任何删除逻辑。新增 PruneOldBackups:保留最近 3 个 homed-upgrade-backup-* 与最近 1 个 homed-broken-*,其余 RemoveAll,在 Apply 成功返回前调用。 只处理名字精确为「前缀 + NowStamp 形态」的目录,不误删 pending 标记 或前缀相近的第三方目录。 H4 结果事件吞错误 + 成功不发 result publishResult 里 `if errMsg != "" { _ = errMsg }` 把失败原因丢弃,payload 也没有 error 字段 ⇒ 订阅方只看到 ok=false。现在 error 字段恒定存在。 Apply 成功路径此前从不发 result,现补发 ok=true。 测试:kernel_apply_test.go 新增 5 个用例(含题目要求的四个函数名)。 --- internal/update/events.go | 15 +- internal/update/gc.go | 143 ++++++++++ internal/update/kernel.go | 87 ++++-- internal/update/kernel_apply_test.go | 395 +++++++++++++++++++++++++++ internal/update/rollback.go | 11 +- 5 files changed, 626 insertions(+), 25 deletions(-) create mode 100644 internal/update/gc.go create mode 100644 internal/update/kernel_apply_test.go 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) From 85fd38b7d14e07ff34d7998f9c95f42ca30964a7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 28 Oct 2026 17:20:40 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(update):=20=E7=BB=9F=E4=B8=80=20dataDir?= =?UTF-8?q?=20=E6=8E=A8=E5=AF=BC=EF=BC=8C=E4=B8=89=E7=AB=AF=E5=85=B1?= =?UTF-8?q?=E7=94=A8=20update.ResolveDataDir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 范围说明:本 commit 与 H1 属同一缺陷的另一面。Apply 现在会拒绝空 dataDir,而三端各自的推导逻辑此前会**真的返回空**(或写死生产路径), 不一起改则 H1 会把所有升级都拒掉、或让 pending 落到内核读不到的地方。 三处原来的推导: · cmd/homed-upgrade/main.go env → 写死 /home/newqqagent · pluginmgr/kernel_update.go env → Dir(pluginDir) · webui/kernel_update.go env → 空(由调用方报错) 问题:① 硬编码 /home/newqqagent 在目录/用户迁移后指向不存在的路径; ② 从 pluginDir 反推与 webui 侧不一致,同一台机器两条升级通道算出 两个 dataDir ⇒ 标记落在 A、第 0 层 update 从 B 读 ⇒ 升级启动 失败时静默不回滚(症状是崩溃循环,极难反查到标记)。 现在三端统一调用 update.ResolveDataDir(exePath): ① HOMEAGENT_DATA_DIR(部署方显式声明,唯一可靠来源) ② /data 兜底(替代原先的硬编码生产路径) 并删掉 cmd/homed-upgrade 里写死的 /home/newqqagent。 本 commit 可单独回退:回退后仅剩「各端自己推导」,与 H1 组合会退化为 「空 dataDir 一律拒绝升级」(安全侧失败,不会产生无回滚能力的升级)。 --- cmd/homed-upgrade/main.go | 17 +++++------ internal/plugins/pluginmgr/kernel_update.go | 34 +++++---------------- internal/plugins/webui/kernel_update.go | 15 ++++----- 3 files changed, 20 insertions(+), 46 deletions(-) diff --git a/cmd/homed-upgrade/main.go b/cmd/homed-upgrade/main.go index dad9ece1..72c5afc1 100644 --- a/cmd/homed-upgrade/main.go +++ b/cmd/homed-upgrade/main.go @@ -42,20 +42,17 @@ import ( ) // fail 打印错误并以非零退出(CLI 专属;库内部一律返回 error)。 -// upgradeDataDir 推导数据目录(升级确认标记的落点)。 +// upgradeDataDir 推导数据目录(升级确认标记 / 备份的落点)。 // // 与生产一致:内核按 `--data ` 运行,标记必须落在同一个 dataDir 下, -// 否则 update 模块读不到它。推导规则: +// 否则 update 模块读不到它。 // -// ① 环境变量 HOMEAGENT_DATA_DIR(部署方显式指定) -// ② 二进制同级的 ../newqqagent(/usr/local/bin/homed → /home/newqqagent) -// —— 太脆弱,改为最常见的部署形态:/usr/local/bin/homed → /home/newqqagent -// ③ 退回 /home/newqqagent(本仓默认部署路径) +// ★ 三端共用 update.ResolveDataDir(CLI / WebUI / agent 工具), +// 不再在这里写死 /home/newqqagent:硬编码在生产目录迁移后会让标记和备份 +// 落到一个**不存在的路径**,而第 0 层 update 从真实的 --data 读 ⇒ 永远 +// 读不到 ⇒ 升级失败时静默不回滚(2026-10-26 迁移教训)。 func upgradeDataDir(binPath string) string { - if d := strings.TrimSpace(os.Getenv("HOMEAGENT_DATA_DIR")); d != "" { - return d - } - return "/home/newqqagent" + return update.ResolveDataDir(binPath) } func fail(format string, a ...interface{}) { diff --git a/internal/plugins/pluginmgr/kernel_update.go b/internal/plugins/pluginmgr/kernel_update.go index ff9bc1cb..8b3507bc 100644 --- a/internal/plugins/pluginmgr/kernel_update.go +++ b/internal/plugins/pluginmgr/kernel_update.go @@ -98,7 +98,7 @@ func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, e bus = p.sdk.EventBus() } - out, err := update.Apply(root, kernelBinPath(p), newDG, kernelDataDir(p), bus) + out, err := update.Apply(root, kernelBinPath(p), newDG, update.ResolveDataDir(kernelBinPath(p)), bus) if err != nil { return map[string]interface{}{"error": err.Error()}, nil } @@ -140,36 +140,16 @@ func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, e return res, nil } -// kernelDataDir 推导数据目录(升级确认标记的落点)。 +// kernelDataDir 推导数据目录(升级确认标记 / 备份的落点)。 // -// 从 pluginDir 反推:/plugins ⇒ 上一级即 dataDir。 -// 与内核 --data 的值一致,update 模块(第 0 层)才能读到同一个标记。 +// ★ 三端共用 update.ResolveDataDir:CLI、WebUI、agent 工具必须算出**同一个** +// 路径,否则 pending 标记落在哪、第 0 层 update 就从哪读,两边一漂移, +// 升级启动失败时就静默不回滚(本文件此前反推 pluginDir 的做法就是这么 +// 与 webui 侧走散的)。 func kernelDataDir(p *Plugin) string { - // /plugins ⇒ 上一级即 dataDir。 - // - // ★ 只 Dir 一次。此前写成 Dir(Dir(...)),把 /home/newqqagent/plugins - // 算成 /home —— pending 标记落到 /home/kernel-upgrade-pending.json, - // 而第 0 层 update 模块从 ks.DataDir 读 ⇒ **永远读不到标记** ⇒ - // agent 发起的升级一旦启动失败就**不会回滚**(静默失效)。 - // ★ 不再写死生产路径(2026-10-26 迁移教训): - // 硬编码 /home/newqqagent 在目录/用户迁移后会让 pending 标记落到一个 - // **已经不存在的目录**,而第 0 层 update 从 ks.DataDir 读 ⇒ 永远读不到 - // ⇒ agent 发起的升级失败时静默不回滚。 - // - // 优先级:显式环境变量(部署方声明自己在哪)→ 从 pluginDir 反推 → 空。 - // 空表示"不知道",由调用方拒绝执行并报错,而不是猜一个路径。 - if d := os.Getenv(dataDirEnv); d != "" { - return d - } - if p.pluginDir != "" { - return filepath.Dir(p.pluginDir) - } - return "" + return update.ResolveDataDir(kernelBinPath(p)) } -// dataDirEnv 是部署方声明数据目录的环境变量(与 webui 侧同名)。 -const dataDirEnv = "HOMEAGENT_DATA_DIR" - // currentKernelVersion 读本机 homed 自报版本(尽力而为)。 func currentKernelVersion(p *Plugin) *update.BinVersion { v, err := update.ReadBinVersion(kernelBinPath(p)) diff --git a/internal/plugins/webui/kernel_update.go b/internal/plugins/webui/kernel_update.go index 144a2f6a..44e7b40c 100644 --- a/internal/plugins/webui/kernel_update.go +++ b/internal/plugins/webui/kernel_update.go @@ -140,15 +140,12 @@ func currentKernelVersion() *update.BinVersion { return v } -// dataDirOf 推导数据目录(升级确认标记的落点)。 +// dataDirOf 推导数据目录(升级确认标记 / 备份的落点)。 // -// 与内核 `--data` 必须一致,否则第 0 层 update 模块读不到 pending 标记。 -// 优先用环境变量显式指定(部署方知道自己在哪),否则退回本机生产路径。 +// ★ 三端共用 update.ResolveDataDir:pending 标记落在哪,第 0 层 update +// 模块就从哪读。两边一漂移,升级启动失败时就**静默不回滚**。 +// 返回空的情况已不存在(ResolveDataDir 有 exe 同级 data 兜底), +// 但仍不写死任何生产绝对路径:迁移后它指向不存在的目录(静默不回滚)。 func dataDirOf(binPath string) string { - if d := os.Getenv("HOMEAGENT_DATA_DIR"); d != "" { - return d - } - // ★ 不写死生产路径:迁移后它会指向一个不存在的目录, - // 让升级 pending 标记落错地方(静默不回滚)。返回空由调用方报错。 - return "" + return update.ResolveDataDir(binPath) } From a8ecb8257c56283a6a3d04bcda0dcebc0a971535 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 28 Oct 2026 17:51:16 +0800 Subject: [PATCH 3/5] =?UTF-8?q?revert:=20=E5=9B=9E=E9=80=80=2085fd38b?= =?UTF-8?q?=EF=BC=88ResolveDataDir=20=E4=BB=8E=20exe=20=E7=8C=9C=20dataDir?= =?UTF-8?q?=EF=BC=8C=E7=94=9F=E4=BA=A7=E5=BF=85=E6=8C=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 85fd38b7d14e07ff34d7998f9c95f42ca30964a7. ## 为什么回退 85fd38b 把三端 dataDir 推导统一成 update.ResolveDataDir(exePath): `HOMEAGENT_DATA_DIR` → `/data` 兜底。方向(三端必须算出 同一个值)是对的,但**兜底那一项在生产上是错的**: ExecStart=/usr/bin/homed --role=guard --data /var/lib/hologram ResolveDataDir("/usr/bin/homed") == "/usr/bin/data" ← 与真实值不符 `HOMEAGENT_DATA_DIR` 在现有部署里并没有设置,所以实际生效的就是这个兜底。 后果两个,都是致命的: ① Apply 第一步要在 dataDir 下建备份目录,而 /usr/bin 在 ProtectSystem=strict 下只读 ⇒ EROFS ⇒ 升级必挂 ② pending 标记落到内核读不到的位置 ⇒ 升级启动失败后静默不回滚 ## 附带问题:空值守卫变成死代码 ecde8dd 的 H1(P1)加的是「dataDir 为空就拒绝升级」。85fd38b 的 ResolveDataDir 兜底**永不为空**(exePath 为空时还会自己去取 KernelBinaryPath),于是三个调用点传进来的 dataDir 恒非空 —— 守卫还在,但守的是一个永远非空的常量,实际不会再拦任何东西。 「不知道」伪装成「知道」比没有守卫更危险:守卫看起来在守,出事时才发现 它从来没生效。所以判据 P1 在 85fd38b 之上是咬不住的。 ## 回退范围 只回退 85fd38b 的三个文件(CLI / pluginmgr / webui 的推导逻辑), 回到 ecde8dd 的状态;不影响 ecde8dd 本身。回退后: · ResolveDataDir 及其所有调用点消失,无悬空引用 · go build ./... 通过 · 三端暂时回到「各端自己推导」——这正是紧随其后的下一个 commit 要替换掉的形态(改为取内核权威源,不做任何推导) 回退 + 后续修复合起来才完整:单看本 commit,dataDir 解析又散回三处; 下一个 commit 用「注入内核真实 -data」取代全部推导。 --- cmd/homed-upgrade/main.go | 17 ++++++----- internal/plugins/pluginmgr/kernel_update.go | 34 ++++++++++++++++----- internal/plugins/webui/kernel_update.go | 15 +++++---- 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/cmd/homed-upgrade/main.go b/cmd/homed-upgrade/main.go index 72c5afc1..dad9ece1 100644 --- a/cmd/homed-upgrade/main.go +++ b/cmd/homed-upgrade/main.go @@ -42,17 +42,20 @@ import ( ) // fail 打印错误并以非零退出(CLI 专属;库内部一律返回 error)。 -// upgradeDataDir 推导数据目录(升级确认标记 / 备份的落点)。 +// upgradeDataDir 推导数据目录(升级确认标记的落点)。 // // 与生产一致:内核按 `--data ` 运行,标记必须落在同一个 dataDir 下, -// 否则 update 模块读不到它。 +// 否则 update 模块读不到它。推导规则: // -// ★ 三端共用 update.ResolveDataDir(CLI / WebUI / agent 工具), -// 不再在这里写死 /home/newqqagent:硬编码在生产目录迁移后会让标记和备份 -// 落到一个**不存在的路径**,而第 0 层 update 从真实的 --data 读 ⇒ 永远 -// 读不到 ⇒ 升级失败时静默不回滚(2026-10-26 迁移教训)。 +// ① 环境变量 HOMEAGENT_DATA_DIR(部署方显式指定) +// ② 二进制同级的 ../newqqagent(/usr/local/bin/homed → /home/newqqagent) +// —— 太脆弱,改为最常见的部署形态:/usr/local/bin/homed → /home/newqqagent +// ③ 退回 /home/newqqagent(本仓默认部署路径) func upgradeDataDir(binPath string) string { - return update.ResolveDataDir(binPath) + if d := strings.TrimSpace(os.Getenv("HOMEAGENT_DATA_DIR")); d != "" { + return d + } + return "/home/newqqagent" } func fail(format string, a ...interface{}) { diff --git a/internal/plugins/pluginmgr/kernel_update.go b/internal/plugins/pluginmgr/kernel_update.go index 8b3507bc..ff9bc1cb 100644 --- a/internal/plugins/pluginmgr/kernel_update.go +++ b/internal/plugins/pluginmgr/kernel_update.go @@ -98,7 +98,7 @@ func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, e bus = p.sdk.EventBus() } - out, err := update.Apply(root, kernelBinPath(p), newDG, update.ResolveDataDir(kernelBinPath(p)), bus) + out, err := update.Apply(root, kernelBinPath(p), newDG, kernelDataDir(p), bus) if err != nil { return map[string]interface{}{"error": err.Error()}, nil } @@ -140,16 +140,36 @@ func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, e return res, nil } -// kernelDataDir 推导数据目录(升级确认标记 / 备份的落点)。 +// kernelDataDir 推导数据目录(升级确认标记的落点)。 // -// ★ 三端共用 update.ResolveDataDir:CLI、WebUI、agent 工具必须算出**同一个** -// 路径,否则 pending 标记落在哪、第 0 层 update 就从哪读,两边一漂移, -// 升级启动失败时就静默不回滚(本文件此前反推 pluginDir 的做法就是这么 -// 与 webui 侧走散的)。 +// 从 pluginDir 反推:/plugins ⇒ 上一级即 dataDir。 +// 与内核 --data 的值一致,update 模块(第 0 层)才能读到同一个标记。 func kernelDataDir(p *Plugin) string { - return update.ResolveDataDir(kernelBinPath(p)) + // /plugins ⇒ 上一级即 dataDir。 + // + // ★ 只 Dir 一次。此前写成 Dir(Dir(...)),把 /home/newqqagent/plugins + // 算成 /home —— pending 标记落到 /home/kernel-upgrade-pending.json, + // 而第 0 层 update 模块从 ks.DataDir 读 ⇒ **永远读不到标记** ⇒ + // agent 发起的升级一旦启动失败就**不会回滚**(静默失效)。 + // ★ 不再写死生产路径(2026-10-26 迁移教训): + // 硬编码 /home/newqqagent 在目录/用户迁移后会让 pending 标记落到一个 + // **已经不存在的目录**,而第 0 层 update 从 ks.DataDir 读 ⇒ 永远读不到 + // ⇒ agent 发起的升级失败时静默不回滚。 + // + // 优先级:显式环境变量(部署方声明自己在哪)→ 从 pluginDir 反推 → 空。 + // 空表示"不知道",由调用方拒绝执行并报错,而不是猜一个路径。 + if d := os.Getenv(dataDirEnv); d != "" { + return d + } + if p.pluginDir != "" { + return filepath.Dir(p.pluginDir) + } + return "" } +// dataDirEnv 是部署方声明数据目录的环境变量(与 webui 侧同名)。 +const dataDirEnv = "HOMEAGENT_DATA_DIR" + // currentKernelVersion 读本机 homed 自报版本(尽力而为)。 func currentKernelVersion(p *Plugin) *update.BinVersion { v, err := update.ReadBinVersion(kernelBinPath(p)) diff --git a/internal/plugins/webui/kernel_update.go b/internal/plugins/webui/kernel_update.go index 44e7b40c..144a2f6a 100644 --- a/internal/plugins/webui/kernel_update.go +++ b/internal/plugins/webui/kernel_update.go @@ -140,12 +140,15 @@ func currentKernelVersion() *update.BinVersion { return v } -// dataDirOf 推导数据目录(升级确认标记 / 备份的落点)。 +// dataDirOf 推导数据目录(升级确认标记的落点)。 // -// ★ 三端共用 update.ResolveDataDir:pending 标记落在哪,第 0 层 update -// 模块就从哪读。两边一漂移,升级启动失败时就**静默不回滚**。 -// 返回空的情况已不存在(ResolveDataDir 有 exe 同级 data 兜底), -// 但仍不写死任何生产绝对路径:迁移后它指向不存在的目录(静默不回滚)。 +// 与内核 `--data` 必须一致,否则第 0 层 update 模块读不到 pending 标记。 +// 优先用环境变量显式指定(部署方知道自己在哪),否则退回本机生产路径。 func dataDirOf(binPath string) string { - return update.ResolveDataDir(binPath) + if d := os.Getenv("HOMEAGENT_DATA_DIR"); d != "" { + return d + } + // ★ 不写死生产路径:迁移后它会指向一个不存在的目录, + // 让升级 pending 标记落错地方(静默不回滚)。返回空由调用方报错。 + return "" } From 1319d6619c9e2aa5683618f9cc1996627f0d7465 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 28 Oct 2026 17:51:52 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(update):=20dataDir=20=E5=8F=96=E5=86=85?= =?UTF-8?q?=E6=A0=B8=E6=9D=83=E5=A8=81=E6=BA=90=20+=20=E5=9B=9E=E6=BB=9A?= =?UTF-8?q?=E5=8F=AF=E4=BF=A1=E6=A0=A1=E9=AA=8C=20+=20marker=20=E5=8E=9F?= =?UTF-8?q?=E5=AD=90=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承上一个 commit(回退 85fd38b)。本 commit 是 R2 主体:B0/B1/B2 三项修复, 以及为 P1–P4 补齐的**行为级**判据。 ━━━ B0 · dataDir 不再推导,取内核真实 -data ━━━ `internal/update/kernel.go` · 删除 ResolveDataDir(env → `/data` 兜底)。 · 新增 SetKernelDataDir / KernelDataDir:值由内核适配层在启动时 用 ks.DataDir 注入。进程内调用方(pluginmgr / webui)直接取, 不做任何推导;取不到返回**空串**。 · DataDirEnv 保留但降级为诊断参考(不再是合法来源)。 `internal/kernelmod/update/mod.go` · Init 里 upd.SetKernelDataDir(ks.DataDir);为空时打响亮告警 (不阻断启动 —— 第 0 层职责是回滚判定,不是参数校验)。 `internal/plugins/pluginmgr/kernel_update.go`、`internal/plugins/webui/kernel_update.go` · kernelDataDir / dataDirOf 都改为 return update.KernelDataDir()。 `cmd/homed-upgrade/main.go` · upgradeDataDir(explicit):① `-data `;② config.db 的 core.daemon.data_dir(只读连接 + PRAGMA query_only=1,不建表不迁移)。 · 两者都拿不到 ⇒ 返回空串 ⇒ `-apply` 在动手之前就 fail 并说明怎么修。 → 这样 P1 的空值守卫重新变成**活的**:拿不到真实 dataDir 就真的会拦。 ━━━ B1 · 回滚可信:回读校验是唯一判据(判据 B1)━━━ `internal/update/rollback.go` — RollbackIfUnconfirmed · rollback.go:315 `rb, verr := readBinVersionStrict(binPath)` 替换后**必须**回读成功且 rb.Version == rec.FromVer,否则 rolled=false + error + **不清 marker**(下次启动再试,并让人看到日志)。 CopyFile 返回 nil 只代表字节搬完了,不代表搬过去的东西能执行。 · rollback.go:254 确认分支同样改用 readBinVersionStrict。 这里曾经用 ReadBinVersion —— 它含 InferVersionFromRunning 回退, 已实测:binPath 是不可执行残文件、marker.ToVer 恰好等于本机 waiter 版本时,会从 waiter **借到**版本号 ⇒ confirmed=true ⇒ 清 marker、 不回滚 ⇒ 机器带着跑不起来的二进制继续启动且失去自愈机会。 该缺陷环境相关(CI 没装 homed/waiter 会绿),故判据不能依赖本机环境。 · 备份缺失 / 回读失败 / 版本不符 / depgraph 回滚失败 —— 四种情形 一律不清 marker。 · 新增 readBinVersionStrict(kernel.go):只认「这个文件自己能报出版本」, 无任何回退;ReadBinVersion 改为其包装器(保留探测用回退), 注释写明它**不能**用作回滚校验。 ━━━ B2 · marker 原子 + 损坏不卡启动(判据 B2)━━━ `internal/update/rollback.go` — MarkPendingUpgrade · 旧实现 os.WriteFile 直接覆写(O_TRUNC),掉电留半截 JSON。 · 现改为:写 .tmp → fsync(文件) → rename(原子) → fsync(目录)。 目录 fsync 失败仅告警(容器/tmpfs 下 EINVAL 不该阻断升级)。 rollback.go:112 `if err := f.Sync(); err != nil {` 即这条链路的第一道闸。 · RollbackIfUnconfirmed 遇到解析不了的 marker:quarantineMarker 改名 留存(不删,保留现场)+ 告警 + return false,"","",nil ⇒ 当作「无待确认升级」继续启动。把内核卡在启动阶段是最差选择: 能执行到这行已证明当前二进制可执行。 `internal/kernelmod/update/mod.go` · 补注释钉住语义:rerr != nil 必须中止启动;但 marker **损坏**不算 失败(已由 RollbackIfUnconfirmed 吞掉并改名留存)。 ━━━ 判据补齐(行为级,修掉 M6 真空洞)━━━ `internal/update/kernel_apply_test.go` · P1 TestApplyRefusesEmptyDataDir 加强:切到 t.TempDir() 并拍快照, 断言拒绝升级时**根本没写任何文件**(旧用例只查「目标二进制没变」, 把守卫挪到预检之后依旧绿 —— 因为 filepath.Join("", prefix) 会把 备份目录建到 CWD)。 · P3 新增 TestApplyPrunesOldBackups:预置 5 个历史备份后跑真实 Apply, 断言旧的被 GC。★ 这是补 M6 的真空洞 —— 旧的变异脚本只跑 `-run TestPruneOldBackups`(只证明函数能跑,3 毫秒),把 Apply 里 那一行 PruneOldBackups(dataDir) 删掉它照样绿;新脚本必须同时跑 TestApplyPrunesOldBackups,删掉调用后立刻报红。 「函数能跑」≠「Apply 真的调了它」。 · B1 新增 TestRollback_RefusesCorruptBackup(零字节/坏备份 ⇒ 必须 回滚失败且不清 marker)、TestRollback_RefusesVersionMismatch (备份版本 ≠ FromVer ⇒ 同样失败)、 TestRollback_ConfirmedBranchIgnoresInferredVersion 与 TestConfirmedBranch_StrictProbeBitesOnUnparseable(确认分支不得 使用推断值;用可注入的 inferHomedPath/inferWaiterPath 造出 「能借到版本」的场景,不依赖本机是否装了 homed/waiter)。 · B2 新增 TestMarkPendingUpgrade_IsAtomic、 TestRollback_CorruptMarkerDoesNotBlockBoot。 `internal/update/rollback_test.go` · TestRollback_RestoresBackup / TestRollback_RollsBackWhenRunningOldVersion 的备份从纯字节 "OLD-GOOD" 改为**真正可执行**的假二进制(1.4.0), 并按版本而非按字节断言。旧的写法只能测「字节搬对了」,测不到 「搬过去的东西能跑」—— 而后者才是回滚能救机器的前提。 `internal/update/kernel.go` · inferHomedPath / inferWaiterPath 提为包级变量(生产默认值不变), 唯一目的是让「确认分支不得使用推断值」这条判据可稳定复现。 ━━━ 判据锚点自检(grep -F 命中数均为 1,行号已核对)━━━ P1 kernel.go:348 if dataDir == "" { P2 kernel.go:370 backup := filepath.Join(dataDir, BackupDirPrefix+ts) P3 kernel.go:449 PruneOldBackups(dataDir) P4 kernel.go:445 publishResult(bus, "kernel", "", true, …) P4 events.go:69 "error": errMsg, B1 rollback.go:254 if cur, cerr := readBinVersionStrict(binPath); cerr == nil { B1 rollback.go:315 rb, verr := readBinVersionStrict(binPath) B2 rollback.go:112 if err := f.Sync(); err != nil { 验证:go build ./... 通过;go test ./internal/update/ 通过。 --- cmd/homed-upgrade/main.go | 108 +++++- internal/kernelmod/update/mod.go | 25 ++ internal/plugins/pluginmgr/kernel_update.go | 37 +- internal/plugins/webui/kernel_update.go | 18 +- internal/update/kernel.go | 119 ++++-- internal/update/kernel_apply_test.go | 379 ++++++++++++++++++++ internal/update/rollback.go | 175 ++++++++- internal/update/rollback_test.go | 39 +- 8 files changed, 802 insertions(+), 98 deletions(-) diff --git a/cmd/homed-upgrade/main.go b/cmd/homed-upgrade/main.go index dad9ece1..e7d42fb9 100644 --- a/cmd/homed-upgrade/main.go +++ b/cmd/homed-upgrade/main.go @@ -30,6 +30,7 @@ package main import ( + "database/sql" "flag" "fmt" "os" @@ -38,24 +39,95 @@ import ( "strings" "time" + _ "github.com/mattn/go-sqlite3" + "github.com/JianFeeeee/HologramAgent/internal/update" ) // fail 打印错误并以非零退出(CLI 专属;库内部一律返回 error)。 -// upgradeDataDir 推导数据目录(升级确认标记的落点)。 +// upgradeDataDir 返回内核**真实**的数据目录(升级确认标记与备份的落点)。 // -// 与生产一致:内核按 `--data ` 运行,标记必须落在同一个 dataDir 下, -// 否则 update 模块读不到它。推导规则: +// ## ★ 不猜路径,只认两个显式来源 // -// ① 环境变量 HOMEAGENT_DATA_DIR(部署方显式指定) -// ② 二进制同级的 ../newqqagent(/usr/local/bin/homed → /home/newqqagent) -// —— 太脆弱,改为最常见的部署形态:/usr/local/bin/homed → /home/newqqagent -// ③ 退回 /home/newqqagent(本仓默认部署路径) -func upgradeDataDir(binPath string) string { - if d := strings.TrimSpace(os.Getenv("HOMEAGENT_DATA_DIR")); d != "" { +// ① `-data `(人工直接给,最高优先) +// ② `config.db` 里的 `core.daemon.data_dir` +// +// 都拿不到就返回**空串**:调用方会因此拒绝 `-apply` 并说明怎么修。 +// 不许"用一个看起来合理的路径顶上"(曾用 `/home/newqqagent` 硬编码, +// 也曾用 `/data` 兜底): +// +// · 硬编码在目录/用户迁移后指向**不存在的路径** +// · exe 兜底实测得出 /usr/bin/data,而生产实际是 +// ExecStart=/usr/bin/homed --role=guard --data /var/lib/hologram +// ⇒ 建备份目录 EROFS(/usr/bin 只读)⇒ 升级必挂; +// ⇒ 标记落到内核读不到的位置 ⇒ 升级失败静默不回滚 +// +// 返回空比返回一个错的值安全得多:前者被守卫拦住,后者要等出事才知道。 +func upgradeDataDir(explicit string) string { + if d := strings.TrimSpace(explicit); d != "" { return d } - return "/home/newqqagent" + return dataDirFromConfigDB() +} + +// dataDirFromConfigDB 从内核配置库读 core.daemon.data_dir。 +// +// ## 为什么要猜 config.db 的位置(而 dataDir 本身不猜) +// +// 循环依赖:data_dir 就在 config.db 里,而 config.db 就在 data_dir 里。 +// 所以只能按“部署惯例”去找它:二进制同级的 ../newqqagent/config.db +// (本仓默认部署形态)+ 几个常见位置。这是找**线索**,不是拿它当结论: +// 读到的 core.daemon.data_dir 是内核自己写下的值,而非我们的推测。 +// +// 只读打开(PRAGMA query_only=1),不建表、不迁移 —— 升级 CLI 绝不能 +// 因为查询配置而改动内核的库(WAL 模式下写入还会与运行中的内核抢锁)。 +func dataDirFromConfigDB() string { + bin, err := os.Executable() + if err != nil { + bin = "homed-upgrade" + } + // 候选顺序:安装形态(/bin/homed-upgrade → /newqqagent) + // 在前,其它常见布局在后。找到第一个能读的即止。 + prefixes := []string{filepath.Dir(filepath.Dir(bin))} + if home, herr := os.UserHomeDir(); herr == nil && home != "" { + prefixes = append(prefixes, home) + } + var candidates []string + for _, p := range prefixes { + candidates = append(candidates, filepath.Join(p, "newqqagent", "config.db")) + } + candidates = append(candidates, "/var/lib/hologram/config.db", "/home/newqqagent/config.db") + + for _, dbPath := range candidates { + if !update.FileExists(dbPath) { + continue + } + if d := readDataDirFromConfigDB(dbPath); d != "" { + fmt.Printf("数据目录来源: %s 的 core.daemon.data_dir = %s\n", dbPath, d) + return d + } + } + return "" +} + +// readDataDirFromConfigDB 只读地取一个 config.db 里的 core.daemon.data_dir。 +func readDataDirFromConfigDB(dbPath string) string { + // 不 import internal/config:那会连带注册表的建表/播种逻辑, + // 对一个只读查询来说太重且会写盘。直接一个只读连接。 + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return "" + } + defer db.Close() + // query_only:结构性地堵死写入(不依赖调用方自觉)。 + if _, err := db.Exec("PRAGMA query_only=1"); err != nil { + return "" + } + var v string + if err := db.QueryRow(`SELECT value FROM config WHERE key = 'core.daemon.data_dir'`).Scan(&v); err != nil { + return "" + } + return strings.TrimSpace(v) } func fail(format string, a ...interface{}) { @@ -69,6 +141,9 @@ func main() { binPath := flag.String("bin", "/usr/local/bin/homed", "当前运行的内核二进制路径") pluginsDir := flag.String("plugins", "/home/newqqagent/plugins", "外部插件目录(探测兼容性用)") apply := flag.Bool("apply", false, "真正执行升级(默认只探测+预检)") + // ★ 显式的 --data 优先;不给则从 config.db 的 core.daemon.data_dir 读。 + // 两者都拿不到时 -apply 直接拒绝(而不是猜一个路径)。 + dataDirFlag := flag.String("data", "", "内核数据目录(= 内核的 --data;不填则从 config.db 的 core.daemon.data_dir 读)") unit := flag.String("unit", "homeagent", "重启用的 systemd 单元名(仅 -apply 时使用)") flag.Parse() @@ -150,7 +225,18 @@ func main() { } // ── 6. 真正升级:预检 → 备份 → pending → 原子替换(**不含重启**)── - out, err := update.Apply(root, *binPath, newDG, upgradeDataDir(*binPath), nil) + // + // ★ dataDir 必须在**动手之前**解析失败就退出:它是备份、pending 标记、 + // 回滚坏二进制的共同落点。拿不到就拒绝升级,而不是猜一个。 + dataDir := upgradeDataDir(*dataDirFlag) + if dataDir == "" { + fail("无法确定内核数据目录(升级确认标记与备份无处可放 ⇒ 无回滚能力)\n"+ + "请显式指定:-data (与内核 unit 的 --data 一致,如 systemctl show %s -p ExecStart)\n"+ + "或确认 %s 存在且含 core.daemon.data_dir", *unit, "/home/newqqagent/config.db") + } + fmt.Printf("数据目录: %s\n", dataDir) + + out, err := update.Apply(root, *binPath, newDG, dataDir, nil) if err != nil { fail("升级失败: %v", err) } diff --git a/internal/kernelmod/update/mod.go b/internal/kernelmod/update/mod.go index 1e1b2691..e47ca980 100644 --- a/internal/kernelmod/update/mod.go +++ b/internal/kernelmod/update/mod.go @@ -45,6 +45,22 @@ func (Module) Init(ks *kernelboot.KernelState) error { st := &upd.State{} ks.Update = st + // ⓪ 把内核**真实**的 --data 注入 update 包。 + // + // 进程内调用方(pluginmgr / webui)用 upd.KernelDataDir() 取值, + // 而不是自己推导路径。推导必错:生产是 + // + // ExecStart=/usr/bin/homed --role=guard --data /var/lib/hologram + // + // 而“exe 同级 data”这种推导会得出 /usr/bin/data(只读目录,建备份必 EROFS; + // 且 pending 标记落到内核读不到的地方 ⇒ 静默不回滚)。 + upd.SetKernelDataDir(ks.DataDir) + if ks.DataDir == "" { + // 不阻断启动(第 0 层职责是回滚判定,不是参数校验), + // 但必须响亮:此时任何升级都会被 Apply 的守卫拒绝。 + log.Printf("[update] ks.DataDir 为空:内核升级将一律被拒绝(无回滚能力)") + } + bin := upd.KernelBinaryPath() st.BinaryPath = bin @@ -61,6 +77,15 @@ func (Module) Init(ks *kernelboot.KernelState) error { } // ② 上一次升级的回滚判定 + // + // ★ rerr != nil 必须中止启动:继续装配等于用一个**已知**回滚失败的 + // 二进制(RollbackIfUnconfirmed 已保证只在"回滚成功且回读校验通过"时 + // 返回 nil,其余失败均不清 marker)。 + // + // ★ 但 marker **损坏**(掉电截断)不算失败:RollbackIfUnconfirmed + // 已经把坏 marker 改名留存并返回 (false,nil),它自己就吞掉了那种情形。 + // 早前版本在这里 `if rerr != nil { return rerr }` 之前把解析错误原样 + // 上抛 ⇒ 内核拒启动、且走不到回滚分支 ⇒ 既起不来也回滚不了。 rolled, from, to, rerr := upd.RollbackIfUnconfirmed(ks.DataDir, bin) if rerr != nil { // 回滚失败必须响亮:继续装配等于用一个已知起不来的版本 diff --git a/internal/plugins/pluginmgr/kernel_update.go b/internal/plugins/pluginmgr/kernel_update.go index ff9bc1cb..69a2bf57 100644 --- a/internal/plugins/pluginmgr/kernel_update.go +++ b/internal/plugins/pluginmgr/kernel_update.go @@ -140,36 +140,21 @@ func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, e return res, nil } -// kernelDataDir 推导数据目录(升级确认标记的落点)。 +// kernelDataDir 返回内核**真实**的数据目录(ks.DataDir,来自 `-data`)。 // -// 从 pluginDir 反推:/plugins ⇒ 上一级即 dataDir。 -// 与内核 --data 的值一致,update 模块(第 0 层)才能读到同一个标记。 +// ★ 取注入值,不做任何推导。此前这里从 pluginDir 反推(Dir(pluginDir))、 +// 又曾改成 env→Dir(pluginDir)→空,两者都错过: +// +// · 一旦与内核 `-data` 不一致,pending 标记落到 A 而第 0 层从 B 读 +// ⇒ 升级启动失败时**静默不回滚**(症状是崩溃循环,极难反查) +// · 而“猜一个看起来合理的路径”比返回空更危险 —— 它让 Apply 的 +// dataDir 空值守卫变成死代码 +// +// 所以:拿不到就返回空串,由 Apply 拒绝升级并报错。 func kernelDataDir(p *Plugin) string { - // /plugins ⇒ 上一级即 dataDir。 - // - // ★ 只 Dir 一次。此前写成 Dir(Dir(...)),把 /home/newqqagent/plugins - // 算成 /home —— pending 标记落到 /home/kernel-upgrade-pending.json, - // 而第 0 层 update 模块从 ks.DataDir 读 ⇒ **永远读不到标记** ⇒ - // agent 发起的升级一旦启动失败就**不会回滚**(静默失效)。 - // ★ 不再写死生产路径(2026-10-26 迁移教训): - // 硬编码 /home/newqqagent 在目录/用户迁移后会让 pending 标记落到一个 - // **已经不存在的目录**,而第 0 层 update 从 ks.DataDir 读 ⇒ 永远读不到 - // ⇒ agent 发起的升级失败时静默不回滚。 - // - // 优先级:显式环境变量(部署方声明自己在哪)→ 从 pluginDir 反推 → 空。 - // 空表示"不知道",由调用方拒绝执行并报错,而不是猜一个路径。 - if d := os.Getenv(dataDirEnv); d != "" { - return d - } - if p.pluginDir != "" { - return filepath.Dir(p.pluginDir) - } - return "" + return update.KernelDataDir() } -// dataDirEnv 是部署方声明数据目录的环境变量(与 webui 侧同名)。 -const dataDirEnv = "HOMEAGENT_DATA_DIR" - // currentKernelVersion 读本机 homed 自报版本(尽力而为)。 func currentKernelVersion(p *Plugin) *update.BinVersion { v, err := update.ReadBinVersion(kernelBinPath(p)) diff --git a/internal/plugins/webui/kernel_update.go b/internal/plugins/webui/kernel_update.go index 144a2f6a..8ba3830a 100644 --- a/internal/plugins/webui/kernel_update.go +++ b/internal/plugins/webui/kernel_update.go @@ -140,15 +140,15 @@ func currentKernelVersion() *update.BinVersion { return v } -// dataDirOf 推导数据目录(升级确认标记的落点)。 +// dataDirOf 返回内核**真实**的数据目录(ks.DataDir,来自 `-data`)。 // -// 与内核 `--data` 必须一致,否则第 0 层 update 模块读不到 pending 标记。 -// 优先用环境变量显式指定(部署方知道自己在哪),否则退回本机生产路径。 +// ★ 取注入值,不做任何推导:pending 标记落在哪,第 0 层 update 模块就从 +// 哪读;两边一漂移,升级失败时就**静默不回滚**。而“猜一个看起来合理的 +// 路径”(曾用过 env → exe 同级 data)会让 Apply 的空值守卫变成死代码—— +// 实测生产是 `--data /var/lib/hologram`,而 exe 推导得 /usr/bin/data +// (只读目录,建备份必 EROFS)。 +// +// 拿不到就返回空串,由 Apply 拒绝升级并报错。 func dataDirOf(binPath string) string { - if d := os.Getenv("HOMEAGENT_DATA_DIR"); d != "" { - return d - } - // ★ 不写死生产路径:迁移后它会指向一个不存在的目录, - // 让升级 pending 标记落错地方(静默不回滚)。返回空由调用方报错。 - return "" + return update.KernelDataDir() } diff --git a/internal/update/kernel.go b/internal/update/kernel.go index 36e5b0d8..5f155e63 100644 --- a/internal/update/kernel.go +++ b/internal/update/kernel.go @@ -22,6 +22,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" "github.com/JianFeeeee/HologramAgent/internal/events" @@ -35,28 +36,48 @@ type BinVersion struct { SDKVersion string } -// readBinVersion 跑 ` --version` 并解析。 +// ReadBinVersion 跑 ` --version` 并解析(含老版本回退,用于**探测**)。 // // ★ 为什么用"跑它自己"而不是读文件里的字符串: // // 版本是 ldflags 注入的,从 ELF 里 grep 只能撞运气;而 --version 是内核 // 自己算出来的,还能一并拿到 commit("同版本号不同 commit"很难察觉)。 func ReadBinVersion(bin string) (*BinVersion, error) { + v, err := readBinVersionStrict(bin) + if err == nil { + return v, nil + } + // ★ 回退:老版本内核没有 --version(它是 2026-10-25 才加的)。 + // + // 这正是 OTA 的**第一次**升级必然撞到的情况:新版工具要读线上版本, + // 而线上那个版本还不支持被读。此时不能失败退出——否则所有人第一次 + // 升级都要人工介入。改为从运行中进程的环境/文件里找线索,找不到就 + // 诚实地说"未知",让版本比较降级(其余预检照常)。 + // + // ★★ 注意:这个回退使 ReadBinVersion **不能**用做回滚校验。 + // 推断出来的版本可能来自**另一个文件**(或运行中的老进程),拿它当 + // "刚拷过去的文件能执行"的证词,会让一个零字节约的坏备份"校验通过"。 + // 回滚路径必须用 readBinVersionStrict(见 rollback.go)。 + if iv := InferVersionFromRunning(); iv != nil { + return iv, nil + } + return nil, fmt.Errorf("执行 --version: %v(老版本内核不支持 --version,且无法从运行进程推断)", err) +} + +// readBinVersionStrict 只认**这个文件自己**能报出版本,任何回退都不要。 +// +// 用于**回滚校验**:我们要证明的是"刚替换上去的那个文件是可执行的且 +// 版本对",而任何形式的推断(读别的路径、问运行中的进程)都会把一个 +// 死文件"验证"成活的。 +func readBinVersionStrict(bin string) (*BinVersion, error) { if _, err := os.Stat(bin); err != nil { return nil, err } out, err := exec.Command(bin, "--version").CombinedOutput() if err != nil { - // ★ 回退:老版本内核没有 --version(它是 2026-10-25 才加的)。 - // - // 这正是 OTA 的**第一次**升级必然撞到的情况:新版工具要读线上版本, - // 而线上那个版本还不支持被读。此时不能失败退出——否则所有人第一次 - // 升级都要人工介入。改为从运行中进程的环境/文件里找线索,找不到就 - // 诚实地说"未知",让版本比较降级(其余预检照常)。 - if v := InferVersionFromRunning(); v != nil { - return v, nil - } - return nil, fmt.Errorf("执行 --version: %v(老版本内核不支持 --version,且无法从运行进程推断)", err) + // ★ 这里**不**做任何回退(与 ReadBinVersion 的关键区别): + // 执行失败就说明这个文件不可用,回退会把死文件验成活的。 + return nil, fmt.Errorf("执行 --version: %v(该文件不可执行或架构不符)", err) } v := &BinVersion{} for _, line := range strings.Split(string(out), "\n") { @@ -247,31 +268,51 @@ func FileSize(p string) int64 { } // DataDirEnv 是部署方声明数据目录的环境变量名。 +// +// ★ 仅作**诊断参考**保留:它**不是** dataDir 的合法来源(部署方很可能不设, +// 而设了也未必与内核实际的 -data 一致)。详见 KernelDataDir 的说明。 const DataDirEnv = "HOMEAGENT_DATA_DIR" -// ResolveDataDir 推导数据目录(升级确认标记、备份、坏二进制留档的落点)。 +// kernelDataDir 是内核**实际**的 --data 值,由适配层(kernelmod/update) +// 在启动时用 ks.DataDir 注入。 +var ( + kernelDataDirMu sync.RWMutex + kernelDataDir string +) + +// SetKernelDataDir 由内核适配层(internal/kernelmod/update)在启动时注入真实的 +// `--data` 值。**不是给普通调用方用的**。 +func SetKernelDataDir(dir string) { + kernelDataDirMu.Lock() + defer kernelDataDirMu.Unlock() + kernelDataDir = dir +} + +// KernelDataDir 返回内核**实际**的数据目录(即 ks.DataDir,来自 `-data`)。 // -// 三端(CLI / WebAPI / agent 工具)**必须**用同一份推导逻辑:pending 标记 -// 落在哪,第 0 层 update 模块就从哪读。两边一旦漂移,升级启动失败时就 -// **静默不回滚**,而症状(崩溃循环)与标记无关,极难反查。 +// ## ★ 为什么不推导(这是本项目交过学费的地方) // -// 推导顺序: +// 曾经实现过一个 ResolveDataDir:`HOMEAGENT_DATA_DIR` → `/data` 兜底。 +// 已实测它在生产上是**错的**: // -// ① HOMEAGENT_DATA_DIR(部署方显式声明,唯一可靠的来源) -// ② /data 兜底(/usr/local/bin/homed → /usr/local/bin/data) +// ExecStart=/usr/bin/homed --role=guard --data /var/lib/hologram +// ResolveDataDir("/usr/bin/homed") == "/usr/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 第一步要在 dataDir 建备份目录,而 /usr/bin 在 +// ProtectSystem=strict 下只读 ⇒ EROFS ⇒ 升级必挂 +// ② pending 标记落到内核读不到的位置 ⇒ 升级失败后静默不回滚 +// +// 所以现在**不猜**:进程内调用方取内核真实注入的值,取不到就返回空串, +// 由 Apply 的"dataDir 为空则拒绝升级"守卫接管。 +// +// 「不知道」伪装成「知道」比没有守卫更危险:守卫看起来在守,实际守了个 +// 永远非空的常量。 +func KernelDataDir() string { + kernelDataDirMu.RLock() + defer kernelDataDirMu.RUnlock() + return kernelDataDir } // Apply 执行真实升级:预检 → 备份 → 写 pending → 原子替换。 @@ -465,12 +506,26 @@ func NowStamp() string { // 2. 读 WebUI 的 /api/v1/agents(那里返回 meta.Version)—— 需服务在跑 // // 都失败则返回 nil,调用方把比较降级为"未知",**不阻断**升级。 +// 推断逻辑读的两个路径。 +// +// ★ 提为变量(而不是写死在函数体里)的唯一目的是**可测**: +// 以前它们直接内联在 InferVersionFromRunning 里,导致“确认分支不得使用 +// 推断值”这条判据只能在**装了 homed/waiter 且 /usr/local/bin 可写**的机器上 +// 验证。那是典型的“CI 假绿、生产真红”形状:跑不到就 Skip,跳着跳着就没人看。 +// +// 生产行为完全不变(默认值就是原来那两个绝对路径,没人会改它们); +// 只有测试会临时指向自己造的假文件,从而能**稳定复现**“借到别人版本”的场景。 +var ( + inferHomedPath = "/usr/local/bin/homed" + inferWaiterPath = "/usr/local/bin/waiter" +) + func InferVersionFromRunning() *BinVersion { // 手段 1:从二进制里找注入的版本串。 // // ldflags 注入的字符串在 rodata 里是裸的,形如 "1.4.0dev"; // 配合 "commit=" 一起找才有意义,所以这里只认成对出现的情况。 - if v := scanBinaryForVersion("/usr/local/bin/homed"); v != nil { + if v := scanBinaryForVersion(inferHomedPath); v != nil { return v } // 手段 2:问 waiter。 @@ -479,7 +534,7 @@ func InferVersionFromRunning() *BinVersion { // 而且它一直支持 --version(输出形如 // `waiter 1.4.0 (commit 98850e1, built 2026-10-17T09:20:25Z)`)。 // 老内核没有 --version,但同批的 waiter 有 —— 用它反推。 - if v := versionFromWaiter("/usr/local/bin/waiter"); v != nil { + if v := versionFromWaiter(inferWaiterPath); v != nil { return v } return nil diff --git a/internal/update/kernel_apply_test.go b/internal/update/kernel_apply_test.go index abeed703..8b206f57 100644 --- a/internal/update/kernel_apply_test.go +++ b/internal/update/kernel_apply_test.go @@ -106,6 +106,22 @@ func TestApplyRefusesEmptyDataDir(t *testing.T) { 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) @@ -131,6 +147,21 @@ func TestApplyRefusesEmptyDataDir(t *testing.T) { 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 ──────────── @@ -393,3 +424,351 @@ func TestApplySuccessEmitsResult(t *testing.T) { } } } + +// ── 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) + } +} + +// ── 确认分支不得使用推断值(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 与备份版本相同就确认)") + } +} diff --git a/internal/update/rollback.go b/internal/update/rollback.go index 9b4e16b7..58a5b8ba 100644 --- a/internal/update/rollback.go +++ b/internal/update/rollback.go @@ -66,6 +66,26 @@ func pendingPath(dataDir string) string { // 由 DoUpgrade 在替换二进制**之前**写:若替换后重启失败,标记一定在。 // 反过来(替换后才写)会在"替换成功但没来得及写标记就崩了"时丢标记, // 于是下次启动不知道该回滚。 +// +// ## ★ 为什么必须临时文件 + fsync + rename +// +// 旧实现直接 os.WriteFile 覆写目标文件。它**不是原子**的:WriteFile +// 先 O_TRUNC 再写,如果这之间掉电(或 OOM-kill、机器硬重启), +// 磁盘上留下的是一个**半截 JSON**。下一轮启动读到半截 JSON 会解析失败, +// 而解析失败在旧代码里被原样上抛 ⇒ 内核拒启动 ⇒ 而回滚分支根本走不到 +// ⇒ **既起不来也回滚不了**(这是所有升级故障里最难救的一种)。 +// +// 所以要: +// +// 写 .tmp → fsync(文件) → rename(原子替换) → fsync(目录) +// +// rename 在同文件系统内是原子的:任何时刻读到的都是**完整的旧版本**或 +// **完整的新版本**,不存在中间态。fsync 目录是为了让 rename 本身也落盘 +// (否则掉电后可能连 rename 都不生效,但留下的仍是完整旧文件 —— 安全方向)。 +// +// 注意:fsync 失败时**不**当作失败处理。有些文件系统(叠加层/容器/tmpfs) +// 不支持对目录 fsync,返回 EINVAL;此时 rename 已完成、内容已正确, +// 为此拒绝升级只会把可用环境弄成不可用。只记录日志。 func MarkPendingUpgrade(dataDir string, rec PendingRecord) error { if dataDir == "" { return fmt.Errorf("dataDir 为空,无法写升级确认标记") @@ -76,8 +96,38 @@ func MarkPendingUpgrade(dataDir string, rec PendingRecord) error { return err } p := pendingPath(dataDir) - if err := os.WriteFile(p, b, 0644); err != nil { - return fmt.Errorf("写 %s: %w", p, err) + tmp := p + ".tmp" + + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("建临时标记 %s: %w", tmp, err) + } + if _, err := f.Write(b); err != nil { + f.Close() + os.Remove(tmp) + return fmt.Errorf("写 %s: %w", tmp, err) + } + // ★ fsync 必须在 Close 之前:Close 只把数据交给内核页缓存, + // 掉电仍可能丢。这里失败要报错(内容没落盘 ⇒ 不能假装标记写好了)。 + if err := f.Sync(); err != nil { + f.Close() + os.Remove(tmp) + return fmt.Errorf("fsync %s: %w", tmp, err) + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return fmt.Errorf("close %s: %w", tmp, err) + } + if err := os.Rename(tmp, p); err != nil { + os.Remove(tmp) + return fmt.Errorf("rename %s → %s: %w", tmp, p, err) + } + // 目录 fsync:让 rename 自身落盘。失败仅告警(见函数头注释)。 + if d, derr := os.Open(dataDir); derr == nil { + if serr := d.Sync(); serr != nil { + log.Printf("[update] fsync dir %s: %v(rename 已完成,内容正确)", dataDir, serr) + } + d.Close() } return nil } @@ -95,6 +145,17 @@ func ClearPendingUpgrade(dataDir string) error { } // ReadPendingUpgrade 读标记;不存在返回 (nil, nil)。 +// +// 返回 error 的两种情况: +// +// · 文件存在但**解析不了**(掉电截断、被外部写坏) +// · 读失败(权限等) +// +// ★ 调用方**不得**因此拒绝启动 —— 见 RollbackIfUnconfirmed 的处理: +// 解析不了的标记意味着"不知道上次升了什么",此时保守做法是**保留现场 +// 并继续启动**,而不是把内核卡死。启动失败比"可能少回滚一次"严重得多: +// 前者是机器不可用,后者只是多跑一个已知能起得来的版本(能跑到这行 +// 说明当前二进制本身是可执行的)。 func ReadPendingUpgrade(dataDir string) (*PendingRecord, error) { b, err := os.ReadFile(pendingPath(dataDir)) if err != nil { @@ -115,13 +176,44 @@ func ReadPendingUpgrade(dataDir string) (*PendingRecord, error) { // 返回 (rolled, from, to, err): // // rolled=false, err=nil —— 没有待确认标记,正常继续 -// rolled=true, err=nil —— 已回滚(from/to 记录版本轨迹) +// rolled=true, err=nil —— 已回滚且**回读校验通过**(from/to 记录版本轨迹) // rolled=*, err!=nil —— 回滚失败,调用方**必须中止启动** // (继续装配等于用一个已知起不来的版本) +// +// ## ★ 回滚的可信性靠"回读校验",不靠"拷贝没报错" +// +// 这是本函数最重要的不变量:CopyFile 返回 nil 只说明字节搬完了, +// **不说明搬过去的东西是可执行的**。备份文件本身可能是零字节 / 截断 / +// 架构不对(例如磁盘满时写坏的备份),拷贝依旧会成功。 +// +// 旧代码就卡在这里:零字节备份 ⇒ CopyFile 成功 ⇒ 清 marker ⇒ +// 返回 rolled=true, err=nil ⇒ 日志打 "rolled back to 1.4.0" ⇒ +// 调用方以为自愈完成继续启动,而磁盘上躺着的是一个不可执行的二进制。 +// 更糟的是 marker 已被清掉 ⇒ **失去第二次自愈机会**。 +// +// 所以现在改成:替换后必须 readBinVersionStrict(binPath) 回读,并且版本 == +// rec.FromVer。任何一环不过都算回滚失败:不清 marker、返回 error、 +// 让调用方停在下边(不用坏二进制继续跑)。 func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error) { rec, err := ReadPendingUpgrade(dataDir) if err != nil { - return false, "", "", err + // ★ 解析不了 ≠ 中止启动。 + // + // marker 被截断(掉电)或写坏时说"不知道上次升了什么"。把内核 + // 卡在启动阶段是最差的选择:能执行到这里已经证明**当前二进制是 + // 可执行的**,而拒绝启动会让机器彻底不可用、且还得人工上机器。 + // + // 保守做法:告警 + 把坏掉的 marker 改名留存(不删,保留现场供 + // 人工判断),然后当作"无待确认升级"继续启动。代价只是可能少 + // 回滚一次;而不是把一台本来能用的机器搞成起不来。 + quarantined, qerr := quarantineMarker(dataDir) + if qerr != nil { + log.Printf("[update] pending marker unreadable (%v) and quarantine failed: %v", err, qerr) + } else { + log.Printf("[update] pending marker unreadable, quarantined to %s: %v", quarantined, err) + } + log.Printf("[update] 继续启动(不因标记损坏而拒绝启动);本次不做自动回滚") + return false, "", "", nil } if rec == nil { return false, "", "", nil @@ -141,8 +233,25 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error // 当前版本 == 标记里的 To ⇒ 新版本**已经跑起来了** ⇒ 确认,清标记,继续启动 // 当前版本 != To(或读不到)⇒ 新版本没能成为运行版本(exec 失败、被 guard 回退等) // ⇒ 回滚到备份 + // ★★★ 确认分支**必须**用严格版本(不可用带推断回退的 ReadBinVersion)。 + // + // 这里曾经用 ReadBinVersion,于是与 B1 是**同一个病**,只是从"回滚后校验" + // 转移到了"确认":ReadBinVersion 在 exec 失败时会回退到 + // InferVersionFromRunning()(读 /usr/local/bin/homed 的字符串、或跑 + // /usr/local/bin/waiter --version)。 + // + // 已实测的事故形态:binPath 是一个不可执行的残文件,marker.ToVer 恰好 + // 等于本机 waiter 的版本 ⇒ 推断回退从 **waiter** 借到 1.4.0/98850e1 + // ⇒ 与 ToVer 相等 ⇒ confirmed=true ⇒ 清 marker、**不回滚** ⇒ 机器带着 + // 跑不起来的二进制继续启动,且失去自愈机会。 + // + // 而这条缺陷是**环境相关**的:没装 homed/waiter 的机器(如 CI)会绿, + // 生产装了就会红 —— 最难查的那一类。所以不能靠"本机恰好有没有"来验。 + // + // 判据只有一条:这个文件自己能不能报出版本。报不出 ⇒ confirmed=false + // ⇒ 走回滚。绝不用推断值去证明"新版已跑起来"。 confirmed := false - if cur, cerr := ReadBinVersion(binPath); cerr == nil { + if cur, cerr := readBinVersionStrict(binPath); cerr == nil { sameVer := cur.Version == rec.ToVer sameCommit := rec.ToCommit == "" || cur.Commit == rec.ToCommit confirmed = sameVer && sameCommit @@ -187,21 +296,69 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error // 原子替换回旧版 if err := CopyFile(backupBin, binPath); err != nil { - return false, "", "", fmt.Errorf("回滚二进制: %w", err) + // ★ 不清 marker:拷贝失败时磁盘上可能已是**半新半旧**的文件, + // 保留标记才有"下次启动再试"的机会。 + return false, "", "", fmt.Errorf("回滚二进制: %w(标记保留,下次启动会再试)", err) } + // ★★★ 回读校验:这是"回滚成功"的**唯一**判据。 + // + // CopyFile 成功只代表字节搬完了。备份若本身是零字节/截断/架构不对 + // (磁盘满时极易发生),搬过去的东西依旧不可执行。旧代码就在这里 + // 清掉 marker 并报 success,于是机器带着一个不可执行的二进制继续启动, + // 而且失去了第二次自愈机会。 + // ★ 回读用**严格**版本:不可执行就是不可执行。 + // + // 不能用 ReadBinVersion:它含"从运行中进程推断"的回退,而这个回退可能 + // 从**另一个文件**(或老进程)拿到版本号,于是把一个零字节的坏备份 + // 验成"回滚成功"—— 这正是本判据要防的那个事故。 + rb, verr := readBinVersionStrict(binPath) + if verr != nil { + // 不清 marker:让下次启动再试(也让人有机会介入) + return false, "", "", fmt.Errorf( + "回滚后回读失败(%s 不可执行): %v;marker 保留(备份可能已损坏,需人工用已知良好版本替换 %s)", + binPath, verr, binPath) + } + if rb.Version != rec.FromVer { + // 版本不符:磁盘上不是我们要回退到的那个版本。不清 marker。 + return false, "", "", fmt.Errorf( + "回滚后版本校验不符:期望 %s(标记里的 FromVer),实际 %s;marker 保留"+ + "(备份可能与标记不匹配,需人工核验 %s)", + rec.FromVer, rb.Version, rec.BackupDir) + } + log.Printf("[update] rollback verified: %s 回读为 %s", binPath, rb.Version) + // 回滚 depgraph.bin(必须与 homed 同批) if backupDG := filepath.Join(rec.BackupDir, "depgraph.bin"); FileExists(backupDG) { if err := CopyFile(backupDG, filepath.Join(filepath.Dir(binPath), "depgraph.bin")); err != nil { - return false, "", "", fmt.Errorf("回滚 depgraph.bin: %w", err) + // ★ 不清 marker:homed 回滚了但 depgraph 没跟上 ⇒ 两者不同批。 + // 这种状态**不能**当成功:内核启动时会因依赖图不匹配而失败, + // 保留 marker 才能让下次启动再试一遍(且人能看到日志)。 + return false, "", "", fmt.Errorf( + "回滚 depgraph.bin: %w(homed 已回滚但 depgraph 未跟上,marker 保留)", err) } } - // 清标记:已回到旧版,不再有"待确认的新版" + // 只有上面全部通过才清 marker:已确认回到旧版且**可执行**, + // 不存在"待确认的新版"。 if err := ClearPendingUpgrade(dataDir); err != nil { log.Printf("[update] clear pending after rollback: %v", err) } - log.Printf("[update] rolled back to %s(备份 %s)", rec.FromVer, rec.BackupDir) + log.Printf("[update] rolled back to %s(备份 %s,已回读校验)", rec.FromVer, rec.BackupDir) return true, rec.FromVer, rec.ToVer, nil } + +// quarantineMarker 把读不懂的 marker 改名留存(不删)。 +// +// 为什么改名而不是删除:那个文件里可能有部分可用信息(比如完整的 +// from_version 但 to_ 被截断),是排查"上次升级到底发生了什么"的唯一线索。 +// 删了就永久没了。改名能让下一次启动干净地当作"无标记",同时保留现场。 +func quarantineMarker(dataDir string) (string, error) { + src := pendingPath(dataDir) + dst := src + ".corrupt-" + NowStamp() + if err := os.Rename(src, dst); err != nil { + return "", err + } + return dst, nil +} diff --git a/internal/update/rollback_test.go b/internal/update/rollback_test.go index 1ba8e07a..5d85a266 100644 --- a/internal/update/rollback_test.go +++ b/internal/update/rollback_test.go @@ -98,15 +98,29 @@ func TestRollback_RestoresBackup(t *testing.T) { bin := filepath.Join(dir, "homed") // 备份目录里放"旧版" + // + // ★ 必须是**真正可执行**的假二进制(版本 1.4.0 = 下面标记里的 FromVer)。 + // 以前这里放的是纯字节 "OLD-GOOD",只能测"字节搬对了",测不到 + // "搬过去的东西能跑" —— 而后者才是回滚能救机器的前提。B1 加了 + // 回读校验后,非可执行备份必须被判失败(见 TestRollback_RefusesCorruptBackup)。 backupDir := filepath.Join(dir, "backup") os.MkdirAll(backupDir, 0755) - oldBin := filepath.Join(backupDir, "homed") - os.WriteFile(oldBin, []byte("OLD-GOOD"), 0755) - os.WriteFile(filepath.Join(backupDir, "depgraph.bin"), []byte("OLD-DG"), 0644) + oldBin := writeVersionScript(t, backupDir, "homed", "1.4.0", "old") + if err := os.WriteFile(filepath.Join(backupDir, "depgraph.bin"), []byte("OLD-DG"), 0644); err != nil { + t.Fatal(err) + } - // 当前是"升级后的坏版本" - os.WriteFile(bin, []byte("NEW-BROKEN"), 0755) - os.WriteFile(filepath.Join(dir, "depgraph.bin"), []byte("NEW-DG"), 0644) + // 当前是"升级后坏掉的版本":半写坏的、根本跑不起来的字节。 + // + // ★ 必须让它**读不出版本**(而不是报 ToVer):按两段式确认的语义, + // 当前版本 == 标记的 ToVer 意味着"新版已跑起来" ⇒ 确认、不回滚。 + // 真实需要回滚的场景恰恰是"新二进制装上去但没跑起来"(exec 失败/半写)。 + if err := os.WriteFile(bin, []byte("NEW-BROKEN"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "depgraph.bin"), []byte("NEW-DG"), 0644); err != nil { + t.Fatal(err) + } MarkPendingUpgrade(dir, PendingRecord{ FromVer: "1.4.0", FromCommit: "old", @@ -125,11 +139,13 @@ func TestRollback_RestoresBackup(t *testing.T) { t.Errorf("版本轨迹应记录 from/to,实际 %s → %s", from, to) } - // ① 二进制回到旧版 - b, _ := os.ReadFile(bin) - if string(b) != "OLD-GOOD" { - t.Errorf("二进制应回滚到旧版,实际 %q", b) + // ① 二进制回到旧版(按**版本**断言,而不是按字节) + if v, verr := ReadBinVersion(bin); verr != nil { + t.Errorf("回滚后的二进制应可执行: %v", verr) + } else if v.Version != "1.4.0" { + t.Errorf("二进制应回滚到 1.4.0,实际 %s", v.Version) } + _ = oldBin // ② depgraph 同批回滚(必须与 homed 一致) dg, _ := os.ReadFile(filepath.Join(dir, "depgraph.bin")) if string(dg) != "OLD-DG" { @@ -293,7 +309,8 @@ func TestRollback_RollsBackWhenRunningOldVersion(t *testing.T) { backupDir := filepath.Join(dir, "backup") os.MkdirAll(backupDir, 0755) - os.WriteFile(filepath.Join(backupDir, "homed"), []byte("OLD-GOOD"), 0755) + // 可执行的 1.4.0(= FromVer):回读校验要能通过 + writeVersionScript(t, backupDir, "homed", "1.4.0", "oldcommit") MarkPendingUpgrade(dir, PendingRecord{ FromVer: "1.4.0", FromCommit: "oldcommit", From 86d1f4c54719c679ad4f2bd56e940af9da5647cd Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 28 Oct 2026 17:56:44 +0800 Subject: [PATCH 5/5] =?UTF-8?q?test(update):=20=E8=A1=A5=20TestMarkPending?= =?UTF-8?q?Upgrade=5FPreservesOldMarkerOnFailure=EF=BC=88=E5=88=A4?= =?UTF-8?q?=E6=8D=AE=20B2c=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么还要再加一条 前两条 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(测试文件),生产代码零改动。 --- internal/update/kernel_apply_test.go | 62 ++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/internal/update/kernel_apply_test.go b/internal/update/kernel_apply_test.go index 8b206f57..6c23f6d3 100644 --- a/internal/update/kernel_apply_test.go +++ b/internal/update/kernel_apply_test.go @@ -644,6 +644,68 @@ func TestMarkPendingUpgrade_IsAtomic(t *testing.T) { } } +// 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 钉住"确认新版已跑起来"