mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
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:
@ -38,6 +38,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/update"
|
||||
)
|
||||
|
||||
|
||||
@ -54,6 +54,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
|
||||
|
||||
@ -70,6 +86,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 {
|
||||
// 回滚失败必须响亮:继续装配等于用一个已知起不来的版本
|
||||
|
||||
@ -163,16 +163,15 @@ func currentKernelVersion() *update.BinVersion {
|
||||
return v
|
||||
}
|
||||
|
||||
// dataDirOf 返回内核**真实**的数据目录(升级确认标记的落点)。
|
||||
// dataDirOf 返回内核**真实**的数据目录(ks.DataDir,来自 `-data`)。
|
||||
//
|
||||
// ★ 取内核注入值(ks.DataDir),不做任何推导:pending 标记落在哪,
|
||||
// 第 0 层 update 模块就从哪读;两边一漂移,升级失败时就**静默不回滚**。
|
||||
// 而“猜一个看起来合理的路径”会让 Apply 的空值守卫变成死代码。
|
||||
// ★ 取注入值,不做任何推导:pending 标记落在哪,第 0 层 update 模块就从
|
||||
// 哪读;两边一漂移,升级失败时就**静默不回滚**。而“猜一个看起来合理的
|
||||
// 路径”(曾用过 env → exe 同级 data)会让 Apply 的空值守卫变成死代码——
|
||||
// 实测生产是 `--data /var/lib/hologram`,而 exe 推导得 /usr/bin/data
|
||||
// (只读目录,建备份必 EROFS)。
|
||||
//
|
||||
// 拿不到就返回空串,由 Apply 拒绝升级并报错。
|
||||
//
|
||||
// ⚠️ 不要再加 HOMEAGENT_DATA_DIR 回退:部署方可能不设,而设了也未必
|
||||
// 与内核实际的 -data 一致 —— 那正是“看起来在工作”的一类错误。
|
||||
func dataDirOf(binPath string) string {
|
||||
return update.KernelDataDir()
|
||||
}
|
||||
|
||||
@ -42,6 +42,16 @@ func publishProgress(bus *events.Bus, scope, name, step, detail string) {
|
||||
}
|
||||
|
||||
// publishResult 发布升级最终结果(成功或失败,含回滚轨迹)。
|
||||
//
|
||||
// ★ error 字段**总是**出现在 payload 里(成功时为空串)。
|
||||
//
|
||||
// 此前失败路径把 errMsg 收进一个 `if errMsg != "" { _ = errMsg }` 分支
|
||||
// 直接丢掉了 —— 于是订阅方只能看到 ok=false,**看不到失败原因**,
|
||||
// 而"为什么失败"正是用户唯一想知道的。字段固定存在(即使为空)还让
|
||||
// 订阅方不必做"字段有没有"的兼容分支。
|
||||
//
|
||||
// ★ 顺序约定:调用方必须在动作**之前**发事件(见文件头注释)。
|
||||
// 成功路径也一样:result 发出后调用方可能立即重启,进程随即消失。
|
||||
func publishResult(bus *events.Bus, scope, name string, ok bool, from, to string, rolledBack bool, errMsg string) {
|
||||
if bus == nil {
|
||||
return
|
||||
@ -56,12 +66,9 @@ func publishResult(bus *events.Bus, scope, name string, ok bool, from, to string
|
||||
"from": from,
|
||||
"to": to,
|
||||
"rolled_back": rolledBack,
|
||||
"error": errMsg,
|
||||
},
|
||||
})
|
||||
if errMsg != "" {
|
||||
// 错误单独放,避免成功路径也带一个空 error 字段
|
||||
_ = errMsg
|
||||
}
|
||||
}
|
||||
|
||||
// publishCheck 发布一次更新检查结果(只读,不改动任何东西)。
|
||||
|
||||
@ -12,14 +12,15 @@ package update
|
||||
//
|
||||
// 不删的后果不是"占点磁盘"这么温和:dataDir 和 pending 标记在同一个
|
||||
// 文件系统上,磁盘被备份吃满之后 pending 标记就写不进去 ⇒ Apply 拒绝
|
||||
// 升级(守卫是对的,但保护频繁触发本身就是故障)。
|
||||
// 升级(H1 的保护是对的,但保护频繁触发本身就是故障)。
|
||||
//
|
||||
// ## 保留策略:upgrade 备 3 个,broken 备 1 个
|
||||
//
|
||||
// 为什么不对称:
|
||||
//
|
||||
// · homed-upgrade-backup-* 是**回滚源**,可能被任意一次"上次升级的
|
||||
// pending 标记"引用。多留几个才能覆盖"升级→重启→再升级"的连环操作。
|
||||
// pending 标记"引用。多留几个才能覆盖"升级→重启→再升级"的连环操作,
|
||||
// 避免 GC 把当下那次升级还需要的那一份删掉。留 3 个够。
|
||||
// · homed-broken-* 是**事后现场**,只有人工排查时才看,且总是看最近
|
||||
// 一次。留 1 个(而非 0 个)是为了不丢现场。
|
||||
//
|
||||
@ -62,13 +63,14 @@ func BrokenDirPath(dataDir, ts string) string {
|
||||
//
|
||||
// 设计上的克制(这几条都是为了"GC 本身不能造成故障"):
|
||||
//
|
||||
// - dataDir 为空 ⇒ 直接返回。空值曾被当作"当前目录",在这类函数里
|
||||
// 那就是灾难(会去删 CWD 下匹配前缀的东西)。
|
||||
// - 任何一次 RemoveAll 失败都只 log,不返回 error —— 调用点在 Apply
|
||||
// 成功路径的末尾,GC 失败绝不能让一次**已经成功的升级**变成失败。
|
||||
// - 只处理目录、只处理名字精确匹配 "<前缀><ts>" 形态的项(ts 必须是
|
||||
// NowStamp 的形状,见 isBackupStamp)—— 不递归、不碰 pending 标记、
|
||||
// 不碰名字碰巧带同样前缀的其它东西。
|
||||
// · dataDir 为空 ⇒ 直接返回。空值曾被当作"当前目录",在这类函数里
|
||||
// 那就是灾难(会去删 CWD 下匹配前缀的东西)。
|
||||
// · 任何一次 RemoveAll 失败都只 log,不返回 error —— 调用点在 Apply
|
||||
// 成功路径的末尾,GC 失败绝不能让一次**已经成功的升级**变成失败。
|
||||
// · 只处理目录、只处理名字精确匹配 "<前缀><ts>" 形态的项(ts 必须是
|
||||
// NowStamp 的形状,见 isBackupStamp)—— 不递归、不碰 pending 标记、
|
||||
// 不碰名字碰巧带同样前缀的其它东西。GC 的删除权只能给"确实是自己
|
||||
// 造的东西"。
|
||||
func PruneOldBackups(dataDir string) {
|
||||
if dataDir == "" {
|
||||
return
|
||||
@ -108,7 +110,7 @@ func pruneByPrefix(dir, prefix string, keep int) {
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
continue // 只删目录:磁盘满时备份里可能还有半截文件,按名字处理不到就跳过
|
||||
}
|
||||
n := e.Name()
|
||||
if !strings.HasPrefix(n, prefix) || len(n) == len(prefix) {
|
||||
|
||||
@ -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
|
||||
|
||||
836
internal/update/kernel_apply_test.go
Normal file
836
internal/update/kernel_apply_test.go
Normal file
@ -0,0 +1,836 @@
|
||||
package update
|
||||
|
||||
// 内核 Apply 的四条高危缺陷回归测试(H1–H4)。
|
||||
//
|
||||
// 这四条的共同点:**失败时不是报错,而是静默产出"看起来成功了"的升级**。
|
||||
// 而升级路径上的静默失败 = 机器进崩溃循环 + 没人知道为什么。所以每个用例
|
||||
// 都刻意去断言"副作用没有发生",而不只是断言"返回了 error"。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||||
)
|
||||
|
||||
// ── 测试辅助 ────────────────────────────────────────────────
|
||||
|
||||
// fakePackageBin 造一个"能被 ReadBinVersion 读出版本"的假二进制。
|
||||
//
|
||||
// ReadBinVersion 会真的去 exec 它并解析 `key=value` 行,所以这里写一个
|
||||
// 最小可执行的 shell 脚本:预检(H2 之后的备份路径检查)能照常通过。
|
||||
func fakePackageBin(t *testing.T, version, commit string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "homed")
|
||||
script := "#!/bin/sh\n" +
|
||||
"echo version=" + version + "\n" +
|
||||
"echo commit=" + commit + "\n" +
|
||||
"echo build_time=2026-01-01T00:00:00Z\n" +
|
||||
"echo sdk=1.4.0\n"
|
||||
if err := os.WriteFile(p, []byte(script), 0755); err != nil {
|
||||
t.Fatalf("写假二进制: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// newPkgRoot 造一个"解包后"的包目录(root 下含 bin/homed)。
|
||||
//
|
||||
// Apply 用 FindFile(root, "homed") 找新二进制,所以目录结构要一致。
|
||||
func newPkgRootFromBin(t *testing.T, version, commit string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "bin"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := fakePackageBin(t, version, commit)
|
||||
if err := CopyFile(bin, filepath.Join(root, "bin", "homed")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// installFakeKernel 在临时目录里造一个"现役内核"(bin/homed + depgraph.bin)。
|
||||
func installFakeKernel(t *testing.T) (binPath, dgPath string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
binPath = filepath.Join(dir, "homed")
|
||||
if err := os.WriteFile(binPath, []byte("#!/bin/sh\necho version=1.4.0\necho commit=old\n"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dgPath = filepath.Join(dir, "depgraph.bin")
|
||||
if err := os.WriteFile(dgPath, []byte("OLD-DEPGRAPH"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return binPath, dgPath
|
||||
}
|
||||
|
||||
// collectResults 订阅 update_result,返回取事件的函数。
|
||||
func collectResults(t *testing.T, bus *events.Bus) func() []*events.Event {
|
||||
t.Helper()
|
||||
var got []*events.Event
|
||||
bus.Subscribe(events.EventUpdateResult, func(e *events.Event) {
|
||||
got = append(got, e)
|
||||
})
|
||||
return func() []*events.Event { return got }
|
||||
}
|
||||
|
||||
// ── H1:dataDir 为空必须拒绝升级 ────────────────────────────
|
||||
|
||||
// TestApplyRefusesEmptyDataDir 钉住"无标记就无回滚 ⇒ 不许升级"。
|
||||
//
|
||||
// ★ 断言的重点不是 error 存在,而是**磁盘上什么都没变**:
|
||||
// 旧代码在 dataDir=="" 时只是跳过写 pending,后面照样替换二进制,
|
||||
// 于是产出一个"升级成功、但新版本起不来时不会回滚"的状态 ——
|
||||
// 这正是崩溃循环的成因。所以这里必须逐字节比对替换前后的内容。
|
||||
func TestApplyRefusesEmptyDataDir(t *testing.T) {
|
||||
binPath, dgPath := installFakeKernel(t)
|
||||
root := newPkgRootFromBin(t, "1.9.9", "newcommit")
|
||||
|
||||
origBin, err := os.ReadFile(binPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDG, err := os.ReadFile(dgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 包内带 depgraph,确保"如果代码继续往下走"它确实会被替换 ——
|
||||
// 否则这个用例测不出 depgraph 被改动。
|
||||
newDG := filepath.Join(root, "bin", "depgraph.bin")
|
||||
if err := os.WriteFile(newDG, []byte("NEW-DEPGRAPH"), 0644); err != nil {
|
||||
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)
|
||||
}
|
||||
if out != nil {
|
||||
t.Errorf("拒绝升级时不应返回 Outcome,实际 %+v", out)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dataDir 为空") {
|
||||
t.Errorf("错误文案应明确指出 dataDir 为空,实际: %v", err)
|
||||
}
|
||||
|
||||
// ① 目标二进制**逐字节未变**
|
||||
gotBin, _ := os.ReadFile(binPath)
|
||||
if string(gotBin) != string(origBin) {
|
||||
t.Errorf("拒绝升级却改动了二进制:\n 期望 %q\n 实际 %q", origBin, gotBin)
|
||||
}
|
||||
// ② depgraph 也未被替换(同批替换的另一半)
|
||||
gotDG, _ := os.ReadFile(dgPath)
|
||||
if string(gotDG) != string(origDG) {
|
||||
t.Errorf("拒绝升级却改动了 depgraph.bin:\n 期望 %q\n 实际 %q", origDG, gotDG)
|
||||
}
|
||||
// ③ 连临时文件都没留下(CopyFile 的 .tmp-upgrade)
|
||||
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 ────────────
|
||||
|
||||
// TestBackupDirUnderDataDir 钉住备份落点。
|
||||
//
|
||||
// 旧代码写死 /root/homed-upgrade-backup-<ts>:服务以
|
||||
// ProtectSystem=strict + ReadWritePaths=/var/lib/hologram 运行,
|
||||
// 非 root 身份对 /root 没有写权限 ⇒ MkdirAll 必 EACCES ⇒ **首次升级必挂**。
|
||||
//
|
||||
// 所以本条既断言"前缀是 dataDir",也断言 dataDir 下真的出现了备份目录
|
||||
// 且里面有两个文件。同时反证:/root 下不该被创建(当 dataDir != /root 时)。
|
||||
func TestBackupDirUnderDataDir(t *testing.T) {
|
||||
binPath, _ := installFakeKernel(t)
|
||||
root := newPkgRootFromBin(t, "1.9.9", "newcommit")
|
||||
dataDir := t.TempDir()
|
||||
|
||||
out, err := Apply(root, binPath, "", dataDir, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Apply 应成功: %v", err)
|
||||
}
|
||||
if out == nil || out.BackupDir == "" {
|
||||
t.Fatal("Outcome.BackupDir 不应为空")
|
||||
}
|
||||
|
||||
// ① 路径前缀必须是 dataDir,而不是 /root
|
||||
if !strings.HasPrefix(out.BackupDir, dataDir+string(os.PathSeparator)) {
|
||||
t.Errorf("备份目录应在 dataDir 下: dataDir=%s, BackupDir=%s", dataDir, out.BackupDir)
|
||||
}
|
||||
if strings.HasPrefix(out.BackupDir, "/root") {
|
||||
t.Errorf("备份目录不能落在 /root(服务无写权限,首次升级必挂): %s", out.BackupDir)
|
||||
}
|
||||
// ② 名字前缀不变(GC 按前缀识别,见 gc.go)
|
||||
if !strings.HasPrefix(filepath.Base(out.BackupDir), BackupDirPrefix) {
|
||||
t.Errorf("备份目录名应以 %q 开头,实际 %s", BackupDirPrefix, filepath.Base(out.BackupDir))
|
||||
}
|
||||
// ③ 备份真的写进去了(homed + depgraph.bin 都存在)
|
||||
if !FileExists(filepath.Join(out.BackupDir, "homed")) {
|
||||
t.Error("备份目录里应有 homed")
|
||||
}
|
||||
if !FileExists(filepath.Join(out.BackupDir, "depgraph.bin")) {
|
||||
t.Error("备份目录里应有 depgraph.bin(与 homed 同批)")
|
||||
}
|
||||
// ④ pending 标记落在 dataDir 下(备份路径与标记同源,才能被回滚读到)
|
||||
if !FileExists(pendingPath(dataDir)) {
|
||||
t.Errorf("pending 标记应在 dataDir 下: %s", pendingPath(dataDir))
|
||||
}
|
||||
rec, _ := ReadPendingUpgrade(dataDir)
|
||||
if rec == nil {
|
||||
t.Fatal("应能读回 pending 标记")
|
||||
}
|
||||
if rec.BackupDir != out.BackupDir {
|
||||
t.Errorf("标记里的 BackupDir 应指向本次备份: 标记=%s, Outcome=%s", rec.BackupDir, out.BackupDir)
|
||||
}
|
||||
}
|
||||
|
||||
// ── H3:备份目录要有 GC ────────────────────────────────────
|
||||
|
||||
// TestPruneOldBackups 钉住保留策略:upgrade 备 3 个,broken 备 1 个。
|
||||
//
|
||||
// ★ "剩下的是最新的"这一条同样重要:若 GC 删错方向(留下最旧的),
|
||||
// 回滚源就会指向早已被替换过多次的版本,回滚等于装回一个更老的版本。
|
||||
//
|
||||
// 排序依据是目录名里的定长时间戳(NowStamp 的 20060102-150405),
|
||||
// 所以这里不用 os.Chtimes 造 mtime,而是直接按名字造 —— 与实现约定一致,
|
||||
// 也避免文件系统时间戳精度/时区差异让测试变成 flaky。
|
||||
func TestPruneOldBackups(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
|
||||
// 5 个备份(升序时间戳:第 i 个越新)
|
||||
backupTS := []string{
|
||||
"20260101-010101", "20260102-010101", "20260103-010101",
|
||||
"20260104-010101", "20260105-010101",
|
||||
}
|
||||
for _, ts := range backupTS {
|
||||
d := filepath.Join(dataDir, BackupDirPrefix+ts)
|
||||
if err := os.MkdirAll(d, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(d, "homed"), []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 2 个坏二进制现场
|
||||
brokenTS := []string{"20260101-020202", "20260102-020202"}
|
||||
for _, ts := range brokenTS {
|
||||
if err := os.MkdirAll(BrokenDirPath(dataDir, ts), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 干扰项:不该被删(pending 标记)与不该被匹配(前缀相近但不是)
|
||||
marker := pendingPath(dataDir)
|
||||
if err := os.WriteFile(marker, []byte(`{"from_version":"1.4.0"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notOurs := filepath.Join(dataDir, BackupDirPrefix+"not-a-timestamp")
|
||||
if err := os.MkdirAll(notOurs, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 一个普通文件(不是目录):GC 只处理目录
|
||||
strayFile := filepath.Join(dataDir, BackupDirPrefix+"20260109-010101")
|
||||
if err := os.WriteFile(strayFile, []byte("i am a file"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
PruneOldBackups(dataDir)
|
||||
|
||||
// ① 备份剩 3 个,且是最新的三个
|
||||
gotBackups := listStampDirs(t, dataDir, BackupDirPrefix)
|
||||
wantBackups := []string{
|
||||
BackupDirPrefix + "20260103-010101",
|
||||
BackupDirPrefix + "20260104-010101",
|
||||
BackupDirPrefix + "20260105-010101",
|
||||
}
|
||||
sort.Strings(gotBackups)
|
||||
sort.Strings(wantBackups)
|
||||
if len(gotBackups) != 3 {
|
||||
t.Fatalf("应保留 3 个备份,实际 %d 个: %v", len(gotBackups), gotBackups)
|
||||
}
|
||||
for i := range wantBackups {
|
||||
if gotBackups[i] != wantBackups[i] {
|
||||
t.Errorf("保留的应是最新的 3 个备份\n 期望 %v\n 实际 %v", wantBackups, gotBackups)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ② broken 剩 1 个,且是最新的那个
|
||||
gotBroken := listStampDirs(t, dataDir, BrokenDirPrefix)
|
||||
if len(gotBroken) != 1 {
|
||||
t.Fatalf("应保留 1 个 homed-broken-*,实际 %d 个: %v", len(gotBroken), gotBroken)
|
||||
}
|
||||
if gotBroken[0] != BrokenDirPrefix+"20260102-020202" {
|
||||
t.Errorf("保留的应是最新的 broken 目录,实际 %s", gotBroken[0])
|
||||
}
|
||||
|
||||
// ③ 没被误删:pending 标记、前缀相近的目录、以及那个同名普通文件
|
||||
if !FileExists(marker) {
|
||||
t.Error("GC 不能删 pending 标记(删了就等于丢掉回滚能力)")
|
||||
}
|
||||
if !FileExists(notOurs) {
|
||||
t.Error("GC 不能删名字前缀相近但不是我们自己造的目录")
|
||||
}
|
||||
if !FileExists(strayFile) {
|
||||
t.Error("GC 只处理目录,不该删同名文件")
|
||||
}
|
||||
|
||||
// ④ 幂等:再跑一次不应有变化
|
||||
PruneOldBackups(dataDir)
|
||||
if n := len(listStampDirs(t, dataDir, BackupDirPrefix)); n != 3 {
|
||||
t.Errorf("GC 应幂等,第二次跑后剩余 %d 个", n)
|
||||
}
|
||||
|
||||
// ⑤ dataDir 为空时**什么都不做**(尤其不能把空串当 CWD 去删)
|
||||
if before := len(listStampDirs(t, dataDir, BackupDirPrefix)); before != 3 {
|
||||
t.Fatalf("前置条件被破坏: %d", before)
|
||||
}
|
||||
PruneOldBackups("")
|
||||
if after := len(listStampDirs(t, dataDir, BackupDirPrefix)); after != 3 {
|
||||
t.Errorf("dataDir 为空时不应改动任何东西(%d → %d)", 3, after)
|
||||
}
|
||||
}
|
||||
|
||||
// listStampDirs 列出 dir 下名字为 "<prefix><ts>" 形态的子目录。
|
||||
//
|
||||
// ★ 必须按时间戳形态过滤,不能用"前缀匹配"代替:GC 本身也按形态
|
||||
// 过滤(不删名字碰巧带同样前缀的东西),用前缀匹配的辅助函数会把
|
||||
// 那些干扰项算进数量,测出来的就不是真实保留个数。
|
||||
func listStampDirs(t *testing.T, dir, prefix string) []string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("读 %s: %v", dir, err)
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || !strings.HasPrefix(e.Name(), prefix) {
|
||||
continue
|
||||
}
|
||||
if isBackupStamp(e.Name()[len(prefix):]) {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── H4:结果事件必须带 error,成功也必须发 result ────────────
|
||||
|
||||
// TestPublishResultCarriesError 钉住"失败原因必须能被订阅方读到"。
|
||||
//
|
||||
// 旧代码把 errMsg 收进 `if errMsg != "" { _ = errMsg }` 直接丢掉,
|
||||
// 于是订阅方只看到 ok=false,**看不到为什么失败** —— 而失败原因正是
|
||||
// 用户唯一想知道的东西("磁盘满"和"二进制不兼容"要采取的动作完全不同)。
|
||||
func TestPublishResultCarriesError(t *testing.T) {
|
||||
bus := events.NewBus()
|
||||
results := collectResults(t, bus)
|
||||
|
||||
publishResult(bus, "kernel", "", false, "1.4.0", "1.4.1", true, "替换 homed: 权限不足")
|
||||
|
||||
got := results()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("应发出 1 个 update_result 事件,实际 %d 个", len(got))
|
||||
}
|
||||
payload := got[0].Payload
|
||||
if payload["ok"] != false {
|
||||
t.Errorf("ok 应为 false,实际 %v", payload["ok"])
|
||||
}
|
||||
errVal, exists := payload["error"]
|
||||
if !exists {
|
||||
t.Fatal("payload 必须带 error 字段(旧代码在这里把它丢掉了)")
|
||||
}
|
||||
msg, _ := errVal.(string)
|
||||
if strings.TrimSpace(msg) == "" {
|
||||
t.Fatal("失败时 payload[error] 不能为空 —— 订阅方靠它决定怎么处置")
|
||||
}
|
||||
if !strings.Contains(msg, "权限不足") {
|
||||
t.Errorf("payload[error] 应保留原始失败原因,实际 %q", msg)
|
||||
}
|
||||
if payload["rolled_back"] != true {
|
||||
t.Errorf("rolled_back 应为 true,实际 %v", payload["rolled_back"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplySuccessEmitsResult 钉住"成功路径也发 result"。
|
||||
//
|
||||
// 旧代码只有失败路径调 publishResult,成功路径什么都不发 ⇒ 订阅方只能靠
|
||||
// "进度停在最后一步且没看到 failed"来猜成功,猜错的方向是"以为还在升级",
|
||||
// 而紧接着进程就重启消失了 —— 用户永远等不到那个结果。
|
||||
func TestApplySuccessEmitsResult(t *testing.T) {
|
||||
binPath, _ := installFakeKernel(t)
|
||||
root := newPkgRootFromBin(t, "1.9.9", "newcommit")
|
||||
dataDir := t.TempDir()
|
||||
|
||||
bus := events.NewBus()
|
||||
results := collectResults(t, bus)
|
||||
|
||||
out, err := Apply(root, binPath, "", dataDir, bus)
|
||||
if err != nil {
|
||||
t.Fatalf("Apply 应成功: %v", err)
|
||||
}
|
||||
|
||||
got := results()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("成功路径必须发出 1 个 update_result 事件,实际 %d 个", len(got))
|
||||
}
|
||||
payload := got[0].Payload
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("ok 应为 true,实际 %v", payload["ok"])
|
||||
}
|
||||
if payload["rolled_back"] != false {
|
||||
t.Errorf("成功时 rolled_back 应为 false,实际 %v", payload["rolled_back"])
|
||||
}
|
||||
if payload["to"] != out.To.Version {
|
||||
t.Errorf("payload[to] 应为新版本 %s,实际 %v", out.To.Version, payload["to"])
|
||||
}
|
||||
// 成功时 error 字段存在且为空:订阅方无需做"字段有没有"的兼容分支
|
||||
if v, exists := payload["error"]; exists {
|
||||
if s, _ := v.(string); s != "" {
|
||||
t.Errorf("成功时 error 应为空串,实际 %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 := newPkgRootFromBin(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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkPendingUpgrade_PreservesOldMarkerOnFailure 钉住 B2 的第二半:
|
||||
// **写失败时不得破坏已有的旧 marker**。
|
||||
//
|
||||
// ★ 为什么 IsAtomic 不够(这是实测出来的缺口):
|
||||
// TestMarkPendingUpgrade_IsAtomic 断言的是「最终状态对」——不留 .tmp、
|
||||
// 内容可解析、可覆写。把 MarkPendingUpgrade 整个退回 os.WriteFile
|
||||
// 这三条**全都满足**,于是那条判据对「原子写」这个真 bug 是哑的。
|
||||
//
|
||||
// 真正能区分两者的可观测差异只有一个:**失败路径上旧文件还在不在**。
|
||||
//
|
||||
// · 原子写:新内容写到 .tmp 崩掉 ⇒ 目标文件仍是**完整旧版本**
|
||||
// · 原地覆写(os.WriteFile / tmp := p):O_TRUNC 先清空再写,
|
||||
// 中途失败 ⇒ 目标文件被**截断**,旧 marker 永久丢失
|
||||
//
|
||||
// 而丢掉旧 marker 的后果正是 B2 要防的那个事故:不知道上次升了什么,
|
||||
// 也失去回滚依据。所以这里用「把 tmp 路径占成目录」逼 OpenFile 必失败
|
||||
// (EISDIR,稳定可复现,不依赖权限/磁盘/平台),再断言旧 marker 原样还在。
|
||||
//
|
||||
// ★ 变异纪律:把 rollback.go 的 `tmp := p + ".tmp"` 改成 `tmp := p`
|
||||
// (即退回原地覆写),本用例必红 —— 那时 OpenFile 打开的是 marker 本身,
|
||||
// O_TRUNC 立刻把它清空,FromVer 变成新值。
|
||||
func TestMarkPendingUpgrade_PreservesOldMarkerOnFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// ① 先写一个完好的旧 marker(模拟「上次升级已有待确认记录」)。
|
||||
old := PendingRecord{FromVer: "1.0.0", FromCommit: "oldcommit", ToVer: "1.1.0", ToCommit: "newok"}
|
||||
if err := MarkPendingUpgrade(dir, old); err != nil {
|
||||
t.Fatalf("预置旧 marker: %v", err)
|
||||
}
|
||||
|
||||
// ② 把临时文件路径占成目录 ⇒ OpenFile(O_CREATE|O_WRONLY) 必 EISDIR。
|
||||
// 这样「写新 marker」一定在中途失败,且失败点与磁盘满/掉电同类
|
||||
// (已打开目标、还没写完全部内容)。
|
||||
tmpPath := pendingPath(dir) + ".tmp"
|
||||
if err := os.Mkdir(tmpPath, 0755); err != nil {
|
||||
t.Fatalf("占用 tmp 路径: %v", err)
|
||||
}
|
||||
|
||||
// ③ 写新的 marker 必须**报错**(不能假装写成功)。
|
||||
// 用 Error 而非 Fatal:让下面的 ④ 也跑完,把「旧 marker 被破坏」
|
||||
// 这个真正的事故形态一并报出来(两个症状都属于同一个病)。
|
||||
if err := MarkPendingUpgrade(dir, PendingRecord{FromVer: "2.0.0", ToVer: "2.1.0"}); err == nil {
|
||||
t.Error("tmp 路径不可写时 MarkPendingUpgrade 必须返回 error(否则调用方以为标记已就绪)")
|
||||
}
|
||||
|
||||
// ④ 旧 marker 必须**原样保留**(这是 B2 的核心承诺)。
|
||||
got, rerr := ReadPendingUpgrade(dir)
|
||||
if rerr != nil {
|
||||
t.Fatalf("写失败后旧 marker 应仍可解析(不允许留下半截 JSON): %v", rerr)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("写失败后旧 marker 丢了:没有回滚依据,且不知道上次升了什么")
|
||||
}
|
||||
if got.FromVer != "1.0.0" {
|
||||
t.Errorf("旧 marker 被破坏了:FromVer 应为 1.0.0(旧值),实际 %q —— "+
|
||||
"说明写新 marker 时原地覆写了目标文件(原子性丢失)", got.FromVer)
|
||||
}
|
||||
if got.ToVer != "1.1.0" {
|
||||
t.Errorf("旧 marker 被破坏了:ToVer 应为 1.1.0(旧值),实际 %q", got.ToVer)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 确认分支不得使用推断值(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 与备份版本相同就确认)")
|
||||
}
|
||||
}
|
||||
@ -89,6 +89,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 为空,无法写升级确认标记")
|
||||
@ -99,8 +119,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
|
||||
}
|
||||
@ -118,6 +168,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 {
|
||||
@ -138,13 +199,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
|
||||
@ -161,15 +253,20 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error
|
||||
// 当前版本 == 标记里的 To ⇒ 新版本**已经跑起来了** ⇒ 确认,清标记,继续启动
|
||||
// 当前版本 != To(或读不到)⇒ 新版本没能成为运行版本(exec 失败、被 guard 回退等)
|
||||
// ⇒ 回滚到备份
|
||||
//
|
||||
// ★★★ 确认分支**必须**用严格版本(不可用带推断回退的 ReadBinVersion)。
|
||||
//
|
||||
// 这里曾经用 ReadBinVersion,于是与回滚校验是同一个病:它在 exec 失败时会
|
||||
// 回退到 InferVersionFromRunning()(读 /usr/local/bin/homed 的字符串、或跑
|
||||
// /usr/local/bin/waiter --version)。已实测的事故形态:binPath 是一个
|
||||
// 不可执行的残文件,marker.ToVer 恰好等于本机 waiter 的版本 ⇒ 推断回退从
|
||||
// **waiter** 借到相同版本 ⇒ 与 ToVer 相等 ⇒ confirmed=true ⇒ 清 marker、
|
||||
// **不回滚** ⇒ 机器带着跑不起来的二进制继续启动,且失去自愈机会。
|
||||
// 这里曾经用 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
|
||||
// ⇒ 走回滚。绝不用推断值去证明"新版已跑起来"。
|
||||
@ -212,6 +309,8 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error
|
||||
log.Printf("[update] create broken-binary dir %s failed: %v", brokenDir, err)
|
||||
} else if err := CopyFileAtomic(binPath, filepath.Join(brokenDir, "homed")); err != nil {
|
||||
// 备份失败不阻断回滚:回滚是主线,留现场是支线
|
||||
log.Printf("[update] create broken-binary dir %s failed: %v", brokenDir, err)
|
||||
} else if err := CopyFile(binPath, filepath.Join(brokenDir, "homed")); err != nil {
|
||||
log.Printf("[update] backup of broken binary failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[update] broken binary kept at %s", brokenDir)
|
||||
@ -251,6 +350,9 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error
|
||||
verr, binPath)
|
||||
}
|
||||
|
||||
// ★★★ 回读校验:这是"回滚成功"的**唯一**判据。
|
||||
//
|
||||
|
||||
// 回滚 depgraph.bin(必须与 homed 同批)
|
||||
//
|
||||
// ★ 三种情况,缺一不可(旧实现只处理了第一种):
|
||||
@ -333,8 +435,26 @@ func verifyRestored(rec *PendingRecord, binPath string) (string, error) {
|
||||
return "", fmt.Errorf("还原内容与备份不一致(期望 %s,实际 %s)",
|
||||
ShortSum(wantSum), ShortSum(gotSum))
|
||||
}
|
||||
// ★ 内容一致还不够:零字节的备份"内容也一致"。
|
||||
//
|
||||
// 旧 marker 没有 digest,内容比对是唯一可用的间接证据,但它对
|
||||
// 「备份本身就是零字节/截断」这种情况失效 —— 拷过去仍是零字节,
|
||||
// 两边 sha256 相等,于是判成"回滚成功",机器带着不可执行的二进制
|
||||
// 继续启动且 marker 已清(实测事故形态,见 TestRollback_RefusesCorruptBackup)。
|
||||
//
|
||||
// 因此补一道**最低可用性**检查:非空且带可执行位。
|
||||
// 刻意**不**要求 `--version`:老内核(1.3.x 及以前)没有这个 flag,
|
||||
// 而它们恰恰是最常见的回滚目标 —— 那正是 PR #31 的回归。
|
||||
if fi, serr := os.Stat(binPath); serr != nil {
|
||||
return "", fmt.Errorf("读取还原后的 %s: %w", binPath, serr)
|
||||
} else if fi.Size() == 0 {
|
||||
return "", fmt.Errorf("回滚后回读失败:还原后的 %s 是零字节,不可执行(备份不可用)", binPath)
|
||||
} else if fi.Mode()&0111 == 0 {
|
||||
return "", fmt.Errorf("回滚后回读失败:还原后的 %s 没有可执行位(模式 %v),不可执行", binPath, fi.Mode())
|
||||
}
|
||||
|
||||
// 备份可能与 marker 的 FromVer 不符(备份被别的升级覆盖过)。
|
||||
// 读得到版本就校,读不到(老内核)就放行 —— 内容一致已经是本分支的上限。
|
||||
// 读得到版本就校,读不到(老内核)就放行 —— 内容一致 + 可执行已是本分支的上限。
|
||||
if v, verr := readBinVersionStrict(binPath); verr == nil {
|
||||
if rec.FromVer != "" && v.Version != rec.FromVer {
|
||||
return "", fmt.Errorf("版本不符:期望 %s(标记里的 FromVer),实际 %s",
|
||||
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user