merge: pr31 — update 回滚安全(原子写 + 回读校验),解 19 处冲突

pr31 与 main 是**双向分叉**,不是单向落后。`ba281a8` 已把它的多数工作
在 main 上重新实现(dataDir 守卫、gc.go、dataDir 解析),但两边各自又
往前走了,所以 6 个文件 19 处冲突要逐条判。

## 合进来的(main 缺)

- **标记原子写**:main 是 `os.WriteFile` 直接覆写,掉电留半截标记;
  改为 `写 .tmp → fsync → close → rename → fsync 目录`
- **H4**:`publishResult` 里 `if errMsg != "" { _ = errMsg }` 把失败原因
  丢了,订阅方只看到 ok=false。现在 error 字段恒定存在
- **回读校验补最低可用性**:main 对无 digest 的旧 marker 只比"内容与备份
  一致",而**零字节备份内容也一致** ⇒ 判成回滚成功、不清 marker 的时机
  也错过了。补"非空 + 带可执行位",刻意**不**要求 `--version`
  (老内核没这个 flag,那正是 PR #31 的回归)
- `kernel_apply_test.go`(836 行,含 B2c `TestMarkPendingUpgrade_PreservesOldMarkerOnFailure`)

## 保留 main 的(pr31 在这几处是旧版或更差)

- **回滚校验用 digest**(`verifyRestored`)而不是 `--version`:
  pr31 要求回滚后能读版本,而 1.3.x 及以前没有 `--version`,
  而它们恰恰是最常见的回滚目标 ⇒ 从旧版升级失败后永久回滚不了
- **`restoreDepGraph` 三态**:原来有+备份有→还原、原来有+备份无→不算成功、
  原来无→删掉新装的。pr31 只处理第一种,会导致「旧 homed + 新 depgraph」
  的崩溃循环
- **marker 在还原完整后才清**:pr31 在还原前就清,还原失败即失去第二次自愈
- **CLI 不重启服务**:pr31 仍带 `restartUnit`,会杀掉整个 cgroup 连带 guard
- **`resolveDataDir`**:pr31 的 `upgradeDataDir` 是它的子集,多扫几个候选前缀

## 冲突处理中的三处重复声明

两边各加了同名函数,自动合并把它们都留下了:

- `readBinVersionStrict` —— 删 pr31 的(main 在 77 行有)
- `kernelDataDirMu/kernelDataDir/SetKernelDataDir/KernelDataDir` —— 删 kernel.go
  里那份(main 在 install.go)
- `newPkgRoot` —— pr31 的重命名 `newPkgRootFromBin`,两套测试各自可用
- rollback.go 里 `verifyRestored` 与 `readBinVersionStrict` 两段校验并存 —— 删后者

顺带修掉删除时误伤的 `ShortSum` 注释,并删掉 pr31 引入但无人调用的
`DataDirEnv`(main 明确不再拿 env 当 dataDir 来源)。

## 验证

    go build ./...                        ✓
    go vet ./...                          ✓
    go test ./... -count=1                62 ok / 0 fail
    go test -race ./internal/update/ ...  ✓
    gofmt -l                              干净
This commit is contained in:
JianFeeeee
2026-10-31 15:56:10 +08:00
9 changed files with 1111 additions and 106 deletions

View File

@ -47,21 +47,41 @@ type BinVersion struct {
// 文件能执行”的证词,会让一个零字节的坏备份“校验通过”。回滚路径用
// digest(见 rollback.go 的 verifyRestored),不用版本。
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") {
@ -179,46 +199,6 @@ func ShortCommit(c string) string {
return c
}
// readBinVersionStrict 只认**这个文件自己**能报出版本,任何回退都不要。
//
// 与 ReadBinVersion 的唯一区别:exec 失败直接返回 error,**不**走
// InferVersionFromRunning。用于“确认分支”这类必须证明“这个文件能用”
// 的判定。
//
// ★ 不要拿它当回滚校验:老内核(1.3.x 及以前)没有 --version,
// 而它们恰恰是最常见的回滚目标。回滚用 digest(verifyRestored)。
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 {
return nil, fmt.Errorf("执行 --version: %v(该文件不可执行或架构不符)", err)
}
v := &BinVersion{}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
k, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "version":
v.Version = val
case "commit":
v.Commit = val
case "build_time":
v.BuildTime = val
case "sdk":
v.SDKVersion = val
}
}
if v.Version == "" {
return nil, fmt.Errorf("--version 输出里没有 version= 行: %q", strings.TrimSpace(string(out)))
}
return v, nil
}
// ShortSum 把 sha256 缩到前 12 位,用于错误消息(完整值对排查无益)。
func ShortSum(s string) string {
if len(s) > 12 {
@ -363,10 +343,10 @@ func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, err
publishProgress(bus, "kernel", "", "verify", "新内核可执行,版本 "+nv.Version)
fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version)
// ── 备份:只备本次会动的文件(不整目录拷贝,那是个几百 MB)──
//
// ★ 备份必须落在 dataDir 下,不能是 /root:服务以 ProtectSystem=strict
// + 非 root 运行,对 /root 没有写权限 ⇒ MkdirAll 必 EACCES。
// ── 备份:只备本次会动的文件(不整目录拷贝,那是几百 MB)──
// ★ 备份必须落在 dataDir 下,不能是 /root:
// 服务以 ProtectSystem=strict + ReadWritePaths=/var/lib/hologram 运行,
// 非 root 身份对 /root 没有写权限 ⇒ MkdirAll 必 EACCES ⇒ 首次升级必挂。
ts := NowStamp()
backup := filepath.Join(dataDir, BackupDirPrefix+ts)
if err := os.MkdirAll(backup, 0755); err != nil {
@ -416,7 +396,8 @@ func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, err
HadDepGraph: hadDepGraph,
Digests: digests,
}); perr != nil {
// 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力。
// 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力,
// 而不带回滚的升级等于赌运气。
return nil, fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr)
}
fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir))
@ -490,13 +471,15 @@ func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, err
fmt.Println("已替换 homed")
publishProgress(bus, "kernel", "", "ready", "新内核已就位,等待重启")
// ★ 成功路径也必须发 result 事件:
// 此前只有失败路径发,订阅方只能靠“进度停在最后一步且没看到 failed”
// 来猜成功,猜错的方向是“以为还在升级”。先发事件再返回:返回后
// 调用方可能立即重启,进程消失。
// ★ 成功路径也必须发 result 事件。
//
// 此前只有失败路径(restore)发 result,成功路径什么都不发 ⇒ 订阅方
// 只能靠"进度停在最后一步且没看到 failed"来猜成功,猜错的方向是
// "以为还在升级"。先发事件再返回:返回后调用方可能立刻重启,进程消失。
publishResult(bus, "kernel", "", true, curVer.Version, nv.Version, false, "")
// 备份必须 GC:旧注释写“确认稳定后可删”,但全仓从来没有一行删除逻辑。
// 备份必须 GC:否则每次升级都在 dataDir 下留几十 MB,旧注释写
// "确认稳定后可删",但全仓从来没有一行删除逻辑。
PruneOldBackups(dataDir)
return &Outcome{
@ -537,12 +520,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。
@ -551,7 +548,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