fix(update): dataDir 取内核权威源 + 回滚可信校验 + marker 原子写

承上一个 commit(回退 85fd38b)。本 commit 是 R2 主体:B0/B1/B2 三项修复,
以及为 P1–P4 补齐的**行为级**判据。

━━━ B0 · dataDir 不再推导,取内核真实 -data ━━━

`internal/update/kernel.go`
  · 删除 ResolveDataDir(env → `<exe>/data` 兜底)。
  · 新增 SetKernelDataDir / KernelDataDir:值由内核适配层在启动时
    用 ks.DataDir 注入。进程内调用方(pluginmgr / webui)直接取,
    不做任何推导;取不到返回**空串**。
  · DataDirEnv 保留但降级为诊断参考(不再是合法来源)。

`internal/kernelmod/update/mod.go`
  · Init 里 upd.SetKernelDataDir(ks.DataDir);为空时打响亮告警
    (不阻断启动 —— 第 0 层职责是回滚判定,不是参数校验)。

`internal/plugins/pluginmgr/kernel_update.go`、`internal/plugins/webui/kernel_update.go`
  · kernelDataDir / dataDirOf 都改为 return update.KernelDataDir()。

`cmd/homed-upgrade/main.go`
  · upgradeDataDir(explicit):① `-data <dir>`;② config.db 的
    core.daemon.data_dir(只读连接 + PRAGMA query_only=1,不建表不迁移)。
  · 两者都拿不到 ⇒ 返回空串 ⇒ `-apply` 在动手之前就 fail 并说明怎么修。

→ 这样 P1 的空值守卫重新变成**活的**:拿不到真实 dataDir 就真的会拦。

━━━ B1 · 回滚可信:回读校验是唯一判据(判据 B1)━━━

`internal/update/rollback.go` — RollbackIfUnconfirmed
  · rollback.go:315 `rb, verr := readBinVersionStrict(binPath)`
    替换后**必须**回读成功且 rb.Version == rec.FromVer,否则
    rolled=false + error + **不清 marker**(下次启动再试,并让人看到日志)。
    CopyFile 返回 nil 只代表字节搬完了,不代表搬过去的东西能执行。
  · rollback.go:254 确认分支同样改用 readBinVersionStrict。
    这里曾经用 ReadBinVersion —— 它含 InferVersionFromRunning 回退,
    已实测:binPath 是不可执行残文件、marker.ToVer 恰好等于本机 waiter
    版本时,会从 waiter **借到**版本号 ⇒ confirmed=true ⇒ 清 marker、
    不回滚 ⇒ 机器带着跑不起来的二进制继续启动且失去自愈机会。
    该缺陷环境相关(CI 没装 homed/waiter 会绿),故判据不能依赖本机环境。
  · 备份缺失 / 回读失败 / 版本不符 / depgraph 回滚失败 —— 四种情形
    一律不清 marker。
  · 新增 readBinVersionStrict(kernel.go):只认「这个文件自己能报出版本」,
    无任何回退;ReadBinVersion 改为其包装器(保留探测用回退),
    注释写明它**不能**用作回滚校验。

━━━ B2 · marker 原子 + 损坏不卡启动(判据 B2)━━━

`internal/update/rollback.go` — MarkPendingUpgrade
  · 旧实现 os.WriteFile 直接覆写(O_TRUNC),掉电留半截 JSON。
  · 现改为:写 <path>.tmp → fsync(文件) → rename(原子) → fsync(目录)。
    目录 fsync 失败仅告警(容器/tmpfs 下 EINVAL 不该阻断升级)。
    rollback.go:112 `if err := f.Sync(); err != nil {` 即这条链路的第一道闸。
  · RollbackIfUnconfirmed 遇到解析不了的 marker:quarantineMarker 改名
    留存(不删,保留现场)+ 告警 + return false,"","",nil
    ⇒ 当作「无待确认升级」继续启动。把内核卡在启动阶段是最差选择:
    能执行到这行已证明当前二进制可执行。

`internal/kernelmod/update/mod.go`
  · 补注释钉住语义:rerr != nil 必须中止启动;但 marker **损坏**不算
    失败(已由 RollbackIfUnconfirmed 吞掉并改名留存)。

━━━ 判据补齐(行为级,修掉 M6 真空洞)━━━

`internal/update/kernel_apply_test.go`
  · P1 TestApplyRefusesEmptyDataDir 加强:切到 t.TempDir() 并拍快照,
    断言拒绝升级时**根本没写任何文件**(旧用例只查「目标二进制没变」,
    把守卫挪到预检之后依旧绿 —— 因为 filepath.Join("", prefix) 会把
    备份目录建到 CWD)。
  · P3 新增 TestApplyPrunesOldBackups:预置 5 个历史备份后跑真实 Apply,
    断言旧的被 GC。★ 这是补 M6 的真空洞 —— 旧的变异脚本只跑
    `-run TestPruneOldBackups`(只证明函数能跑,3 毫秒),把 Apply 里
    那一行 PruneOldBackups(dataDir) 删掉它照样绿;新脚本必须同时跑
    TestApplyPrunesOldBackups,删掉调用后立刻报红。
    「函数能跑」≠「Apply 真的调了它」。
  · B1 新增 TestRollback_RefusesCorruptBackup(零字节/坏备份 ⇒ 必须
    回滚失败且不清 marker)、TestRollback_RefusesVersionMismatch
    (备份版本 ≠ FromVer ⇒ 同样失败)、
    TestRollback_ConfirmedBranchIgnoresInferredVersion 与
    TestConfirmedBranch_StrictProbeBitesOnUnparseable(确认分支不得
    使用推断值;用可注入的 inferHomedPath/inferWaiterPath 造出
    「能借到版本」的场景,不依赖本机是否装了 homed/waiter)。
  · B2 新增 TestMarkPendingUpgrade_IsAtomic、
    TestRollback_CorruptMarkerDoesNotBlockBoot。

`internal/update/rollback_test.go`
  · TestRollback_RestoresBackup / TestRollback_RollsBackWhenRunningOldVersion
    的备份从纯字节 "OLD-GOOD" 改为**真正可执行**的假二进制(1.4.0),
    并按版本而非按字节断言。旧的写法只能测「字节搬对了」,测不到
    「搬过去的东西能跑」—— 而后者才是回滚能救机器的前提。

`internal/update/kernel.go`
  · inferHomedPath / inferWaiterPath 提为包级变量(生产默认值不变),
    唯一目的是让「确认分支不得使用推断值」这条判据可稳定复现。

━━━ 判据锚点自检(grep -F 命中数均为 1,行号已核对)━━━

	P1  kernel.go:348     if dataDir == "" {
	P2  kernel.go:370     backup := filepath.Join(dataDir, BackupDirPrefix+ts)
	P3  kernel.go:449     PruneOldBackups(dataDir)
	P4  kernel.go:445     publishResult(bus, "kernel", "", true, …)
	P4  events.go:69      "error":       errMsg,
	B1  rollback.go:254   if cur, cerr := readBinVersionStrict(binPath); cerr == nil {
	B1  rollback.go:315   rb, verr := readBinVersionStrict(binPath)
	B2  rollback.go:112   if err := f.Sync(); err != nil {

验证:go build ./... 通过;go test ./internal/update/ 通过。
This commit is contained in:
JianFeeeee
2026-10-28 17:51:52 +08:00
parent a8ecb8257c
commit 1319d6619c
8 changed files with 802 additions and 98 deletions

View File

@ -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 跑 `<bin> --version` 并解析。
// ReadBinVersion 跑 `<bin> --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` → `<exe目录>/data` 兜底。
// 已实测它在生产上是**错的**:
//
// ① HOMEAGENT_DATA_DIR(部署方显式声明,唯一可靠的来源)
// ② <exe 所在目录>/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