Files
HomeAgent/internal/plugins/pluginmgr/kernel_update.go
JianFeeeee 1319d6619c 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/ 通过。
2026-10-28 17:51:52 +08:00

224 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// kernel_update 工具的 agent 侧实现。
//
// ## 三个入口共享一个底层(架构约束)
//
// agent 工具 kernel_update ┐
// WebAPI /kernel/update ├─ internal/update(CheckRelease / DoUpgrade)
// CLI homed-upgrade ┘
//
// 三份实现必然漂移("agent 说没有新版、WebUI 却升了"),所以入口层只做
// 参数解析与结果呈现。
//
// ## 为什么实现放在 pluginmgr 而不是 agent/core
//
// 1. 与 plugin_update 对称(都在 pluginmgr,用户认知一致)
// 2. agent/core 拿不到 SDK 句柄,而升级完成后要 RequestKernelRestart
// (那是 sdk.KernelControlAPI)
// 3. 与内核工具分派(toolcall.go)不同:这里走插件的 RegisterTool,
// 不需要改核心包
//
// ## 两阶段(用户口径)
//
// apply=false(默认)→ 只查 GitHub Release 与本地版本比较,不改动任何东西
// apply=true → 解包 → 校验 → 备份 → 原子替换(**不重启**)
// → 请求 guard 重拉 → 新内核启动时由 update 模块确认或回滚
//
// ★ 重启为什么不在 internal/update 里做:worker 是被守护方,只有 guard 知道
// 这次退出该不该重拉(exit 42 → 重拉且不计失败轮次)。详见
// cmd/homed/kernel_control.go。
package pluginmgr
import (
"fmt"
"os"
"path/filepath"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/update"
)
// kernelUpdateCheck 是 kernel_update 的检查半边(apply=false)。
func (p *Plugin) kernelUpdateCheck() (interface{}, error) {
cur := currentKernelVersion(p)
info := update.CheckRelease(cur)
return map[string]interface{}{
"apply": false,
"current": info.Current,
"latest": info.Latest,
"has_update": info.HasUpdate,
"asset": info.Asset,
"release": info.LatestURL,
"reason": info.Reason,
"hint": "确认后传 apply=true 执行升级(会替换二进制并重启内核,连接断开数秒)",
}, nil
}
// kernelUpdateApply 是 kernel_update 的执行半边(apply=true)。
//
// 来源二选一(update.ParseSource):
//
// · pkg 非空 → 用本机已有的安装包
// · release=true → 从仓库最新 GitHub Release 自动下载
//
// ★ 顺序(此前是反的,会出真事故):
//
// 先 Apply(备份→pending→替换),**再**请求重启。
// 反过来(先请求重启)会在新二进制还没就位时就重拉,
// 或重启恰好发生在替换中途 ⇒ 跑起来的是半新半旧的文件。
//
// ★ 重启走既有的 p.sdk.KernelControl()(restartCh → exit 42 → guard 重拉)。
// 它依赖 kernelControlAllowed 白名单 —— pluginmgr 已加入(否则该接口恒为 nil,
// 以前写在那里等于静默不重启)。
func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, error) {
src, err := update.ParseSource(pkgPath, release)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
tmp, err := os.MkdirTemp("", "kernel-upgrade-*")
if err != nil {
return nil, fmt.Errorf("建临时目录: %w", err)
}
defer os.RemoveAll(tmp)
root, rel, err := src.Resolve(tmp)
if err != nil {
return map[string]interface{}{"error": "取包失败: " + err.Error()}, nil
}
newBin := update.FindFile(root, "homed")
newDG := update.FindFile(root, "depgraph.bin")
if newBin == "" {
return map[string]interface{}{"error": "包内找不到 homed"}, nil
}
// 走事件总线:升级的每一步都让 WebUI 看得到。
var bus *events.Bus
if p.sdk != nil {
bus = p.sdk.EventBus()
}
out, err := update.Apply(root, kernelBinPath(p), newDG, kernelDataDir(p), bus)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
// 产物已就位 ⇒ 走**既有**重启通道(KernelControlAPI → restartCh → exit 42
// → guard 用新二进制重拉)。不自己 systemctl,也不另开通道。
//
// ★ 顺序关键:Apply 完成**之后**才请求重启。反过来会在新二进制还没
// 就位时重拉,或重启恰好落在替换中途。
if kc := p.sdk.KernelControl(); kc != nil {
if rerr := kc.RequestKernelRestart("kernel_update apply"); rerr != nil {
return map[string]interface{}{
"applied": true,
"error": "新内核已就位,但请求重启失败: " + rerr.Error(),
"hint": "手动重启服务即可;pending 标记会让首次启动自动确认或回滚",
}, nil
}
} else {
// 拿不到通道时**如实报告**,不要静默当成功:否则用户以为已升级,
// 而进程还在跑旧镜像;pending 标记会在下次重启时被判"未确认"→ 回滚。
return map[string]interface{}{
"applied": true,
"error": "新内核已就位,但本插件没有被授予内核控制通道(kernelControlAllowed),无法请求重启",
"hint": "手动重启服务完成升级;或改用 WebUI 的 /api/v1/kernel/update",
}, nil
}
res := map[string]interface{}{
"applied": true,
"from": out.From.Version,
"to": out.To.Version,
"backup": out.BackupDir,
"message": "新内核已就位,正在由 guard 重拉。重启后若新版本无法启动,第 0 层 update 模块会自动回滚。",
}
if rel != nil {
res["release"] = rel.Latest
res["asset"] = rel.Asset
}
return res, nil
}
// kernelDataDir 返回内核**真实**的数据目录(ks.DataDir,来自 `-data`)。
//
// ★ 取注入值,不做任何推导。此前这里从 pluginDir 反推(Dir(pluginDir))、
// 又曾改成 env→Dir(pluginDir)→空,两者都错过:
//
// · 一旦与内核 `-data` 不一致,pending 标记落到 A 而第 0 层从 B 读
// ⇒ 升级启动失败时**静默不回滚**(症状是崩溃循环,极难反查)
// · 而“猜一个看起来合理的路径”比返回空更危险 —— 它让 Apply 的
// dataDir 空值守卫变成死代码
//
// 所以:拿不到就返回空串,由 Apply 拒绝升级并报错。
func kernelDataDir(p *Plugin) string {
return update.KernelDataDir()
}
// currentKernelVersion 读本机 homed 自报版本(尽力而为)。
func currentKernelVersion(p *Plugin) *update.BinVersion {
v, err := update.ReadBinVersion(kernelBinPath(p))
if err != nil {
return nil
}
return v
}
// kernelBinPath 是本机 homed 的路径。
//
// 由 pluginDir 推导(<data>/../homed 不总是成立),优先按**本进程**
// 的可执行路径:homed 与内核同二进制,这是最可靠的来源。
func kernelBinPath(p *Plugin) string {
if exe, err := os.Executable(); err == nil && filepath.Base(exe) == "homed" {
return exe
}
if _, err := os.Stat("/usr/local/bin/homed"); err == nil {
return "/usr/local/bin/homed"
}
return "homed"
}
// onUpdateRequest 处理来自其他插件的更新请求(EventBus)。
//
// 请求方不必知道"怎么更新",只需发一个事件:
//
// scope: "kernel" | "plugin"
// name: 插件名(scope=plugin 时必填)
// pkg: 本机安装包路径(可选)
// release: bool,从仓库最新 Release 自动取包(可选)
//
// 为什么必须**异步**:升级会替换二进制并(对内核)请求重启,
// 本进程随后消失。事件处理器是同步投递的(见 events.Bus.Publish),
// 在处理器里同步做完会让发布方卡住直到进程被杀。
func (p *Plugin) onUpdateRequest(evt *events.Event) {
if evt == nil || evt.Payload == nil {
return
}
scope, _ := evt.Payload["scope"].(string)
name, _ := evt.Payload["name"].(string)
pkgPath, _ := evt.Payload["pkg"].(string)
release, _ := evt.Payload["release"].(bool)
go func() {
var err error
switch scope {
case "kernel":
_, err = p.kernelUpdateApply(pkgPath, release)
case "plugin":
if name == "" {
err = fmt.Errorf("plugin 更新请求缺 name")
break
}
// 复用既有 plugin_update 的 apply 通路(范围即该插件名)
_, err = p.pluginUpdate(true, name)
default:
err = fmt.Errorf("未知更新范围 %q(来源 %s)", scope, evt.Source)
}
if err != nil {
// 事件是单向的,没有返回值可取 ⇒ 失败必须留日志,
// 否则请求方看到的就是"发了、没反应"。
fmt.Fprintf(os.Stderr, "[pluginmgr] update_request (%s/%s) failed: %v\n", scope, name, err)
}
}()
}