mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-02 15:27:33 +00:00
承上一个 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/ 通过。
347 lines
12 KiB
Go
347 lines
12 KiB
Go
package update
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// ── 升级确认与回滚的判据 ────────────────────────────────────
|
||
//
|
||
// 这些钉住"启动失败自动回滚"这个核心承诺。它一旦失效,症状是
|
||
// 升级后机器进崩溃循环、只能人工上机器救 —— 而那时用户看不到任何提示。
|
||
|
||
// 判据 1:写标记 → 能读回,字段不丢。
|
||
func TestPendingMarker_RoundTrip(t *testing.T) {
|
||
dir := t.TempDir()
|
||
rec := PendingRecord{
|
||
FromVer: "1.4.0", FromCommit: "aaa111",
|
||
ToVer: "1.4.1", ToCommit: "bbb222",
|
||
BackupDir: "/root/homed-upgrade-backup-x",
|
||
}
|
||
if err := MarkPendingUpgrade(dir, rec); err != nil {
|
||
t.Fatalf("写标记: %v", err)
|
||
}
|
||
got, err := ReadPendingUpgrade(dir)
|
||
if err != nil {
|
||
t.Fatalf("读标记: %v", err)
|
||
}
|
||
if got == nil {
|
||
t.Fatal("应读到标记")
|
||
}
|
||
if got.FromCommit != rec.FromCommit || got.ToCommit != rec.ToCommit || got.BackupDir != rec.BackupDir {
|
||
t.Errorf("字段丢失: %+v", got)
|
||
}
|
||
if got.At.IsZero() {
|
||
t.Error("At 应被自动填上(诊断用)")
|
||
}
|
||
}
|
||
|
||
// 判据 2:没有标记时读回 (nil, nil) —— 不是 error。
|
||
//
|
||
// 这是绝大多数启动的形态(没有待确认升级)。若这里返回 error,
|
||
// 每次启动都会走"回滚失败"分支 ⇒ 内核永远起不来。
|
||
func TestPendingMarker_AbsentIsNotError(t *testing.T) {
|
||
got, err := ReadPendingUpgrade(t.TempDir())
|
||
if err != nil {
|
||
t.Fatalf("无标记不应报错: %v", err)
|
||
}
|
||
if got != nil {
|
||
t.Errorf("无标记应返回 nil,实际 %+v", got)
|
||
}
|
||
}
|
||
|
||
// 判据 3:清标记后再读应为 nil。
|
||
func TestPendingMarker_Clear(t *testing.T) {
|
||
dir := t.TempDir()
|
||
if err := MarkPendingUpgrade(dir, PendingRecord{FromVer: "a", ToVer: "b"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ClearPendingUpgrade(dir); err != nil {
|
||
t.Fatalf("清标记: %v", err)
|
||
}
|
||
if got, _ := ReadPendingUpgrade(dir); got != nil {
|
||
t.Errorf("清后应读不到,实际 %+v", got)
|
||
}
|
||
// 幂等:再清一次不报错(update 模块每次启动都调)
|
||
if err := ClearPendingUpgrade(dir); err != nil {
|
||
t.Errorf("重复清除应幂等: %v", err)
|
||
}
|
||
}
|
||
|
||
// 判据 4:RollbackIfUnconfirmed 在无标记时不动二进制。
|
||
func TestRollback_NoMarkerNoOp(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := filepath.Join(dir, "homed")
|
||
os.WriteFile(bin, []byte("current"), 0755)
|
||
|
||
rolled, _, _, err := RollbackIfUnconfirmed(dir, bin)
|
||
if err != nil {
|
||
t.Fatalf("无标记不应报错: %v", err)
|
||
}
|
||
if rolled {
|
||
t.Error("无标记不应触发回滚")
|
||
}
|
||
b, _ := os.ReadFile(bin)
|
||
if string(b) != "current" {
|
||
t.Errorf("二进制不应被改动,实际 %q", b)
|
||
}
|
||
}
|
||
|
||
// 判据 5:有标记且备份存在 ⇒ 回滚,二进制回到旧版。
|
||
//
|
||
// 这是核心路径:上次升级留下的版本没能启动成功。
|
||
func TestRollback_RestoresBackup(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := filepath.Join(dir, "homed")
|
||
|
||
// 备份目录里放"旧版"
|
||
//
|
||
// ★ 必须是**真正可执行**的假二进制(版本 1.4.0 = 下面标记里的 FromVer)。
|
||
// 以前这里放的是纯字节 "OLD-GOOD",只能测"字节搬对了",测不到
|
||
// "搬过去的东西能跑" —— 而后者才是回滚能救机器的前提。B1 加了
|
||
// 回读校验后,非可执行备份必须被判失败(见 TestRollback_RefusesCorruptBackup)。
|
||
backupDir := filepath.Join(dir, "backup")
|
||
os.MkdirAll(backupDir, 0755)
|
||
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)
|
||
}
|
||
|
||
// 当前是"升级后坏掉的版本":半写坏的、根本跑不起来的字节。
|
||
//
|
||
// ★ 必须让它**读不出版本**(而不是报 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",
|
||
ToVer: "1.4.1", ToCommit: "new",
|
||
BackupDir: backupDir,
|
||
})
|
||
|
||
rolled, from, to, err := RollbackIfUnconfirmed(dir, bin)
|
||
if err != nil {
|
||
t.Fatalf("回滚不应失败: %v", err)
|
||
}
|
||
if !rolled {
|
||
t.Fatal("有待确认标记时必须回滚")
|
||
}
|
||
if from != "1.4.0" || to != "1.4.1" {
|
||
t.Errorf("版本轨迹应记录 from/to,实际 %s → %s", from, to)
|
||
}
|
||
|
||
// ① 二进制回到旧版(按**版本**断言,而不是按字节)
|
||
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" {
|
||
t.Errorf("depgraph 应同批回滚,实际 %q", dg)
|
||
}
|
||
// ③ 标记已清(否则下次启动又回滚一次)
|
||
if got, _ := ReadPendingUpgrade(dir); got != nil {
|
||
t.Error("回滚后应清除标记")
|
||
}
|
||
// ④ 坏版本被留档(万一回滚目标也有问题,能回到现场)
|
||
entries, _ := os.ReadDir(dir)
|
||
found := false
|
||
for _, e := range entries {
|
||
if len(e.Name()) > 12 && e.Name()[:12] == "homed-broken" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Error("坏版本应被留档到 homed-broken-*")
|
||
}
|
||
}
|
||
|
||
// 判据 6:备份不存在 ⇒ 回滚失败(返回 error),**不静默继续**。
|
||
//
|
||
// 静默继续的后果:用一个已知起不来的版本装配 ⇒ 崩溃循环。
|
||
// 必须让调用方中止启动,把问题暴露给人。
|
||
func TestRollback_MissingBackupIsLoud(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := filepath.Join(dir, "homed")
|
||
os.WriteFile(bin, []byte("NEW-BROKEN"), 0755)
|
||
|
||
MarkPendingUpgrade(dir, PendingRecord{
|
||
FromVer: "1.4.0", BackupDir: filepath.Join(dir, "nonexistent-backup"),
|
||
})
|
||
|
||
rolled, _, _, err := RollbackIfUnconfirmed(dir, bin)
|
||
if err == nil {
|
||
t.Fatal("备份不存在时必须报错(静默继续 = 用已知起不来的版本装配)")
|
||
}
|
||
if rolled {
|
||
t.Error("回滚失败时 rolled 应为 false")
|
||
}
|
||
// 错误必须可执行:说出备份路径与标记路径,人能照着修
|
||
if !containsStr2(err.Error(), "nonexistent-backup") {
|
||
t.Errorf("错误应指出备份路径,实际: %v", err)
|
||
}
|
||
if !containsStr2(err.Error(), "kernel-upgrade-pending") {
|
||
t.Errorf("错误应指出标记路径(删它即可放弃回滚),实际: %v", err)
|
||
}
|
||
}
|
||
|
||
// 判据 7:标记写坏(JSON 损坏)⇒ 报错并提示可删。
|
||
func TestPendingMarker_CorruptIsActionable(t *testing.T) {
|
||
dir := t.TempDir()
|
||
p := pendingPath(dir)
|
||
os.WriteFile(p, []byte("{ not json"), 0644)
|
||
|
||
_, err := ReadPendingUpgrade(dir)
|
||
if err == nil {
|
||
t.Fatal("损坏的标记必须报错")
|
||
}
|
||
if !containsStr2(err.Error(), "删掉") {
|
||
t.Errorf("错误应说明可删掉该文件,实际: %v", err)
|
||
}
|
||
}
|
||
|
||
func containsStr2(s, sub string) bool {
|
||
for i := 0; i+len(sub) <= len(s); i++ {
|
||
if s[i:i+len(sub)] == sub {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 判据 8:State.Summary 必须带回滚信息(healthcheck 要展示)。
|
||
func TestStateSummary_CarriesRollback(t *testing.T) {
|
||
st := &State{
|
||
Version: "1.4.0", Commit: "abc123", SDKVersion: "1.3.0",
|
||
RolledBack: true, RolledFrom: "1.4.0", RolledTo: "1.4.1",
|
||
}
|
||
m := st.Summary()
|
||
if m["rolled_back"] != true {
|
||
t.Error("Summary 应带回滚标记")
|
||
}
|
||
if m["rolled_from"] != "1.4.0" || m["rolled_to"] != "1.4.1" {
|
||
t.Errorf("Summary 应带回滚轨迹,实际 %v", m)
|
||
}
|
||
// ShortCommit 只对**超过 8 位**的 commit 截短;6 位的原样返回。
|
||
// 用长 commit 才能验到截短行为。
|
||
st2 := &State{Version: "1.4.0", Commit: "abcdef1234567890"}
|
||
if m2 := st2.Summary(); m2["commit"] != "abcdef12" {
|
||
t.Errorf("长 commit 应被截到 8 位,实际 %v", m2["commit"])
|
||
}
|
||
}
|
||
|
||
// 判据 9:nil State 的 Summary 不 panic(healthcheck 可能在模块未就绪时调用)。
|
||
func TestStateSummary_NilSafe(t *testing.T) {
|
||
var st *State
|
||
if got := st.Summary(); got != nil {
|
||
t.Errorf("nil State 应返回 nil,实际 %v", got)
|
||
}
|
||
}
|
||
|
||
// 判据 10:标记里的时间会被填上(诊断"这次升级是什么时候试的")。
|
||
func TestPendingMarker_TimestampSet(t *testing.T) {
|
||
dir := t.TempDir()
|
||
before := time.Now().Add(-time.Second)
|
||
MarkPendingUpgrade(dir, PendingRecord{FromVer: "a", ToVer: "b"})
|
||
got, _ := ReadPendingUpgrade(dir)
|
||
if got.At.Before(before) {
|
||
t.Errorf("At 应接近当前时间,实际 %v", got.At)
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:升级后的**第一次启动**必须确认,不能回滚。
|
||
//
|
||
// 真机实测(2026-10-26)踩到的缺陷:标记在替换**之前**写,所以升级后第一次
|
||
// 启动必然看到它。原实现"见标记就回滚"⇒ 每次升级都被立刻撤销:
|
||
//
|
||
// -apply 报"✅ 升级完成"
|
||
// 重启后日志:ROLLED BACK kernel 1.4.0dev → 1.4.0dev(上一次升级未能确认启动)
|
||
//
|
||
// 升级因此永远不可能生效。正确语义是比对版本:
|
||
// 当前跑的 == 标记里的 To ⇒ 升级已生效 ⇒ 确认并清标记。
|
||
func TestRollback_ConfirmsWhenRunningTargetVersion(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := writeVersionScript(t, dir, "homed", "1.4.1", "newcommit")
|
||
|
||
backupDir := filepath.Join(dir, "backup")
|
||
os.MkdirAll(backupDir, 0755)
|
||
os.WriteFile(filepath.Join(backupDir, "homed"), []byte("OLD-GOOD"), 0755)
|
||
|
||
MarkPendingUpgrade(dir, PendingRecord{
|
||
FromVer: "1.4.0", FromCommit: "old",
|
||
ToVer: "1.4.1", ToCommit: "newcommit",
|
||
BackupDir: backupDir,
|
||
})
|
||
|
||
rolled, _, _, err := RollbackIfUnconfirmed(dir, bin)
|
||
if err != nil {
|
||
t.Fatalf("确认不应失败: %v", err)
|
||
}
|
||
if rolled {
|
||
t.Fatal("★★★ 已在跑升级后的版本,却被回滚了 —— 升级永远无法生效")
|
||
}
|
||
// 标记必须被清掉,否则下次启动又判一次
|
||
if reced, _ := ReadPendingUpgrade(dir); reced != nil {
|
||
t.Error("确认成功后应清除 pending 标记")
|
||
}
|
||
// 二进制不能被改动
|
||
if b, _ := os.ReadFile(bin); !strings.Contains(string(b), "1.4.1") {
|
||
t.Error("确认路径不应改写二进制")
|
||
}
|
||
}
|
||
|
||
// ★★★ 判据:当前版本**不是**升级目标 ⇒ 必须回滚(升级没生效/被回退)。
|
||
func TestRollback_RollsBackWhenRunningOldVersion(t *testing.T) {
|
||
dir := t.TempDir()
|
||
bin := writeVersionScript(t, dir, "homed", "1.4.0", "oldcommit")
|
||
|
||
backupDir := filepath.Join(dir, "backup")
|
||
os.MkdirAll(backupDir, 0755)
|
||
// 可执行的 1.4.0(= FromVer):回读校验要能通过
|
||
writeVersionScript(t, backupDir, "homed", "1.4.0", "oldcommit")
|
||
|
||
MarkPendingUpgrade(dir, PendingRecord{
|
||
FromVer: "1.4.0", FromCommit: "oldcommit",
|
||
ToVer: "1.4.1", ToCommit: "newcommit",
|
||
BackupDir: backupDir,
|
||
})
|
||
|
||
rolled, from, to, err := RollbackIfUnconfirmed(dir, bin)
|
||
if err != nil {
|
||
t.Fatalf("回滚不应失败: %v", err)
|
||
}
|
||
if !rolled {
|
||
t.Fatal("跑的还是旧版本,说明升级没生效,必须回滚")
|
||
}
|
||
if from != "1.4.0" || to != "1.4.1" {
|
||
t.Errorf("版本轨迹不对: %s → %s", from, to)
|
||
}
|
||
}
|
||
|
||
// writeVersionScript 造一个能被 ReadBinVersion 读出版本的假二进制。
|
||
//
|
||
// 为什么用脚本而不是随便写字节:ReadBinVersion 靠**执行** `<bin> --version`
|
||
// 取版本,写死字节的假文件根本跑不起来(现有用例走的就是"读不到 ⇒ 回滚"
|
||
// 分支,所以那些用例测不到"确认"这条路径)。
|
||
func writeVersionScript(t *testing.T, dir, name, ver, commit string) string {
|
||
t.Helper()
|
||
p := filepath.Join(dir, name)
|
||
body := "#!/bin/sh\nprintf 'version=" + ver + "\\ncommit=" + commit + "\\n'\n"
|
||
if err := os.WriteFile(p, []byte(body), 0755); err != nil {
|
||
t.Fatalf("写假二进制: %v", err)
|
||
}
|
||
return p
|
||
}
|