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