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",