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)
}

View File

@ -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 {
// 回滚失败必须响亮:继续装配等于用一个已知起不来的版本

View File

@ -140,36 +140,21 @@ func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, e
return res, nil
}
// kernelDataDir 推导数据目录(升级确认标记的落点)。
// kernelDataDir 返回内核**真实**的数据目录(ks.DataDir,来自 `-data`)。
//
// 从 pluginDir 反推:<dataDir>/plugins ⇒ 上一级即 dataDir。
// 与内核 --data 的值一致,update 模块(第 0 层)才能读到同一个标记。
// ★ 取注入值,不做任何推导。此前这里从 pluginDir 反推(Dir(pluginDir))、
// 又曾改成 env→Dir(pluginDir)→空,两者都错过:
//
// · 一旦与内核 `-data` 不一致,pending 标记落到 A 而第 0 层从 B 读
// ⇒ 升级启动失败时**静默不回滚**(症状是崩溃循环,极难反查)
// · 而“猜一个看起来合理的路径”比返回空更危险 —— 它让 Apply 的
// dataDir 空值守卫变成死代码
//
// 所以:拿不到就返回空串,由 Apply 拒绝升级并报错。
func kernelDataDir(p *Plugin) string {
// <dataDir>/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))

View File

@ -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()
}

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

View File

@ -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 与备份版本相同就确认)")
}
}

View File

@ -66,6 +66,26 @@ func pendingPath(dataDir string) string {
// 由 DoUpgrade 在替换二进制**之前**写:若替换后重启失败,标记一定在。
// 反过来(替换后才写)会在"替换成功但没来得及写标记就崩了"时丢标记,
// 于是下次启动不知道该回滚。
//
// ## ★ 为什么必须临时文件 + fsync + rename
//
// 旧实现直接 os.WriteFile 覆写目标文件。它**不是原子**的:WriteFile
// 先 O_TRUNC 再写,如果这之间掉电(或 OOM-kill、机器硬重启),
// 磁盘上留下的是一个**半截 JSON**。下一轮启动读到半截 JSON 会解析失败,
// 而解析失败在旧代码里被原样上抛 ⇒ 内核拒启动 ⇒ 而回滚分支根本走不到
// ⇒ **既起不来也回滚不了**(这是所有升级故障里最难救的一种)。
//
// 所以要:
//
// 写 <path>.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
}

View File

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