fix(update): 补上内核升级缺失的四道安全闸(H1–H4)

四条缺陷的共同点都是「静默产出一个看起来成功的升级」,而失败的表现
是机器进崩溃循环且没人知道原因。

H1 dataDir 为空必须拒绝升级
  Apply 此前只在 dataDir 非空时写 pending,空值仅跳过标记、后照常替换
  二进制 ⇒ 产出「无回滚能力」的升级。现改为在动任何文件之前直接失败。
  另新增导出 update.ResolveDataDir(env → <exe>/data 兜底)。

H2 备份目录不能写 /root
  backup 原为 /root/homed-upgrade-backup-<ts>,服务在 ProtectSystem=strict
  + ReadWritePaths=/var/lib/hologram 下运行,非 root 对 /root 无写权限
  ⇒ MkdirAll 必 EACCES ⇒ 首次升级必挂。改为落在 dataDir 下。
  rollback 的 homed-broken-<ts> 同样从(通常只读的)二进制目录挪到 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 个用例(含题目要求的四个函数名)。
This commit is contained in:
JianFeeeee
2026-10-28 17:20:35 +08:00
parent 7f68d2b1bf
commit ecde8dd515
5 changed files with 626 additions and 25 deletions

View File

@ -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 发布一次更新检查结果(只读,不改动任何东西)。

143
internal/update/gc.go Normal file
View File

@ -0,0 +1,143 @@
package update
// 备份目录的垃圾回收。
//
// ## 为什么必须有 GC
//
// 每次 Apply 都在 dataDir 下留一个 homed-upgrade-backup-<ts>(homed +
// depgraph.bin,通常几十 MB),每次启动时回滚坏二进制还会多一个
// homed-broken-<ts>。旧代码里 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>" 形态的项(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)
}
}

View File

@ -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(部署方显式声明,唯一可靠的来源)
// ② <exe 所在目录>/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,

View File

@ -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-<ts>:服务以
// ProtectSystem=strict + ReadWritePaths=/var/lib/hologram 运行,
// 非 root 身份对 /root 没有写权限 ⇒ MkdirAll 必 EACCES ⇒ **首次升级必挂**。
//
// 所以本条既断言"前缀是 dataDir",也断言 dataDir 下真的出现了备份目录
// 且里面有两个文件。同时反证:/root 下不该被创建(当 dataDir != /root 时)。
func TestBackupDirUnderDataDir(t *testing.T) {
binPath, _ := installFakeKernel(t)
root := newPkgRoot(t, "1.9.9", "newcommit")
dataDir := t.TempDir()
out, err := Apply(root, binPath, "", dataDir, nil)
if err != nil {
t.Fatalf("Apply 应成功: %v", err)
}
if out == nil || out.BackupDir == "" {
t.Fatal("Outcome.BackupDir 不应为空")
}
// ① 路径前缀必须是 dataDir,而不是 /root
if !strings.HasPrefix(out.BackupDir, dataDir+string(os.PathSeparator)) {
t.Errorf("备份目录应在 dataDir 下: dataDir=%s, BackupDir=%s", dataDir, out.BackupDir)
}
if strings.HasPrefix(out.BackupDir, "/root") {
t.Errorf("备份目录不能落在 /root(服务无写权限,首次升级必挂): %s", out.BackupDir)
}
// ② 名字前缀不变(GC 按前缀识别,见 gc.go)
if !strings.HasPrefix(filepath.Base(out.BackupDir), BackupDirPrefix) {
t.Errorf("备份目录名应以 %q 开头,实际 %s", BackupDirPrefix, filepath.Base(out.BackupDir))
}
// ③ 备份真的写进去了(homed + depgraph.bin 都存在)
if !FileExists(filepath.Join(out.BackupDir, "homed")) {
t.Error("备份目录里应有 homed")
}
if !FileExists(filepath.Join(out.BackupDir, "depgraph.bin")) {
t.Error("备份目录里应有 depgraph.bin(与 homed 同批)")
}
// ④ pending 标记落在 dataDir 下(备份路径与标记同源,才能被回滚读到)
if !FileExists(pendingPath(dataDir)) {
t.Errorf("pending 标记应在 dataDir 下: %s", pendingPath(dataDir))
}
rec, _ := ReadPendingUpgrade(dataDir)
if rec == nil {
t.Fatal("应能读回 pending 标记")
}
if rec.BackupDir != out.BackupDir {
t.Errorf("标记里的 BackupDir 应指向本次备份: 标记=%s, Outcome=%s", rec.BackupDir, out.BackupDir)
}
}
// ── H3:备份目录要有 GC ────────────────────────────────────
// TestPruneOldBackups 钉住保留策略:upgrade 备 3 个,broken 备 1 个。
//
// ★ "剩下的是最新的"这一条同样重要:若 GC 删错方向(留下最旧的),
// 回滚源就会指向早已被替换过多次的版本,回滚等于装回一个更老的版本。
//
// 排序依据是目录名里的定长时间戳(NowStamp 的 20060102-150405),
// 所以这里不用 os.Chtimes 造 mtime,而是直接按名字造 —— 与实现约定一致,
// 也避免文件系统时间戳精度/时区差异让测试变成 flaky。
func TestPruneOldBackups(t *testing.T) {
dataDir := t.TempDir()
// 5 个备份(升序时间戳:第 i 个越新)
backupTS := []string{
"20260101-010101", "20260102-010101", "20260103-010101",
"20260104-010101", "20260105-010101",
}
for _, ts := range backupTS {
d := filepath.Join(dataDir, BackupDirPrefix+ts)
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "homed"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
// 2 个坏二进制现场
brokenTS := []string{"20260101-020202", "20260102-020202"}
for _, ts := range brokenTS {
if err := os.MkdirAll(BrokenDirPath(dataDir, ts), 0755); err != nil {
t.Fatal(err)
}
}
// 干扰项:不该被删(pending 标记)与不该被匹配(前缀相近但不是)
marker := pendingPath(dataDir)
if err := os.WriteFile(marker, []byte(`{"from_version":"1.4.0"}`), 0644); err != nil {
t.Fatal(err)
}
notOurs := filepath.Join(dataDir, BackupDirPrefix+"not-a-timestamp")
if err := os.MkdirAll(notOurs, 0755); err != nil {
t.Fatal(err)
}
// 一个普通文件(不是目录):GC 只处理目录
strayFile := filepath.Join(dataDir, BackupDirPrefix+"20260109-010101")
if err := os.WriteFile(strayFile, []byte("i am a file"), 0644); err != nil {
t.Fatal(err)
}
PruneOldBackups(dataDir)
// ① 备份剩 3 个,且是最新的三个
gotBackups := listStampDirs(t, dataDir, BackupDirPrefix)
wantBackups := []string{
BackupDirPrefix + "20260103-010101",
BackupDirPrefix + "20260104-010101",
BackupDirPrefix + "20260105-010101",
}
sort.Strings(gotBackups)
sort.Strings(wantBackups)
if len(gotBackups) != 3 {
t.Fatalf("应保留 3 个备份,实际 %d 个: %v", len(gotBackups), gotBackups)
}
for i := range wantBackups {
if gotBackups[i] != wantBackups[i] {
t.Errorf("保留的应是最新的 3 个备份\n 期望 %v\n 实际 %v", wantBackups, gotBackups)
break
}
}
// ② broken 剩 1 个,且是最新的那个
gotBroken := listStampDirs(t, dataDir, BrokenDirPrefix)
if len(gotBroken) != 1 {
t.Fatalf("应保留 1 个 homed-broken-*,实际 %d 个: %v", len(gotBroken), gotBroken)
}
if gotBroken[0] != BrokenDirPrefix+"20260102-020202" {
t.Errorf("保留的应是最新的 broken 目录,实际 %s", gotBroken[0])
}
// ③ 没被误删:pending 标记、前缀相近的目录、以及那个同名普通文件
if !FileExists(marker) {
t.Error("GC 不能删 pending 标记(删了就等于丢掉回滚能力)")
}
if !FileExists(notOurs) {
t.Error("GC 不能删名字前缀相近但不是我们自己造的目录")
}
if !FileExists(strayFile) {
t.Error("GC 只处理目录,不该删同名文件")
}
// ④ 幂等:再跑一次不应有变化
PruneOldBackups(dataDir)
if n := len(listStampDirs(t, dataDir, BackupDirPrefix)); n != 3 {
t.Errorf("GC 应幂等,第二次跑后剩余 %d 个", n)
}
// ⑤ dataDir 为空时**什么都不做**(尤其不能把空串当 CWD 去删)
if before := len(listStampDirs(t, dataDir, BackupDirPrefix)); before != 3 {
t.Fatalf("前置条件被破坏: %d", before)
}
PruneOldBackups("")
if after := len(listStampDirs(t, dataDir, BackupDirPrefix)); after != 3 {
t.Errorf("dataDir 为空时不应改动任何东西(%d → %d)", 3, after)
}
}
// listStampDirs 列出 dir 下名字为 "<prefix><ts>" 形态的子目录。
//
// ★ 必须按时间戳形态过滤,不能用"前缀匹配"代替:GC 本身也按形态
// 过滤(不删名字碰巧带同样前缀的东西),用前缀匹配的辅助函数会把
// 那些干扰项算进数量,测出来的就不是真实保留个数。
func listStampDirs(t *testing.T, dir, prefix string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("读 %s: %v", dir, err)
}
var out []string
for _, e := range entries {
if !e.IsDir() || !strings.HasPrefix(e.Name(), prefix) {
continue
}
if isBackupStamp(e.Name()[len(prefix):]) {
out = append(out, e.Name())
}
}
sort.Strings(out)
return out
}
// ── H4:结果事件必须带 error,成功也必须发 result ────────────
// TestPublishResultCarriesError 钉住"失败原因必须能被订阅方读到"。
//
// 旧代码把 errMsg 收进 `if errMsg != "" { _ = errMsg }` 直接丢掉,
// 于是订阅方只看到 ok=false,**看不到为什么失败** —— 而失败原因正是
// 用户唯一想知道的东西("磁盘满"和"二进制不兼容"要采取的动作完全不同)。
func TestPublishResultCarriesError(t *testing.T) {
bus := events.NewBus()
results := collectResults(t, bus)
publishResult(bus, "kernel", "", false, "1.4.0", "1.4.1", true, "替换 homed: 权限不足")
got := results()
if len(got) != 1 {
t.Fatalf("应发出 1 个 update_result 事件,实际 %d 个", len(got))
}
payload := got[0].Payload
if payload["ok"] != false {
t.Errorf("ok 应为 false,实际 %v", payload["ok"])
}
errVal, exists := payload["error"]
if !exists {
t.Fatal("payload 必须带 error 字段(旧代码在这里把它丢掉了)")
}
msg, _ := errVal.(string)
if strings.TrimSpace(msg) == "" {
t.Fatal("失败时 payload[error] 不能为空 —— 订阅方靠它决定怎么处置")
}
if !strings.Contains(msg, "权限不足") {
t.Errorf("payload[error] 应保留原始失败原因,实际 %q", msg)
}
if payload["rolled_back"] != true {
t.Errorf("rolled_back 应为 true,实际 %v", payload["rolled_back"])
}
}
// TestApplySuccessEmitsResult 钉住"成功路径也发 result"。
//
// 旧代码只有失败路径调 publishResult,成功路径什么都不发 ⇒ 订阅方只能靠
// "进度停在最后一步且没看到 failed"来猜成功,猜错的方向是"以为还在升级",
// 而紧接着进程就重启消失了 —— 用户永远等不到那个结果。
func TestApplySuccessEmitsResult(t *testing.T) {
binPath, _ := installFakeKernel(t)
root := newPkgRoot(t, "1.9.9", "newcommit")
dataDir := t.TempDir()
bus := events.NewBus()
results := collectResults(t, bus)
out, err := Apply(root, binPath, "", dataDir, bus)
if err != nil {
t.Fatalf("Apply 应成功: %v", err)
}
got := results()
if len(got) != 1 {
t.Fatalf("成功路径必须发出 1 个 update_result 事件,实际 %d 个", len(got))
}
payload := got[0].Payload
if payload["ok"] != true {
t.Errorf("ok 应为 true,实际 %v", payload["ok"])
}
if payload["rolled_back"] != false {
t.Errorf("成功时 rolled_back 应为 false,实际 %v", payload["rolled_back"])
}
if payload["to"] != out.To.Version {
t.Errorf("payload[to] 应为新版本 %s,实际 %v", out.To.Version, payload["to"])
}
// 成功时 error 字段存在且为空:订阅方无需做"字段有没有"的兼容分支
if v, exists := payload["error"]; exists {
if s, _ := v.(string); s != "" {
t.Errorf("成功时 error 应为空串,实际 %q", s)
}
}
}

View File

@ -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)