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

@ -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 <dir>` 运行,标记必须落在同一个 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 <dir>`(人工直接给,最高优先)
// ② `config.db` 里的 `core.daemon.data_dir`
//
// 都拿不到就返回**空串**:调用方会因此拒绝 `-apply` 并说明怎么修。
// 不许"用一个看起来合理的路径顶上"(曾用 `/home/newqqagent` 硬编码,
// 也曾用 `<exe目录>/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"
}
// 候选顺序:安装形态(<prefix>/bin/homed-upgrade → <prefix>/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 <dir>(与内核 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)
}