mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
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:
@ -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
143
internal/update/gc.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
395
internal/update/kernel_apply_test.go
Normal file
395
internal/update/kernel_apply_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user