fix(update): 重启交还 guard、OTA 支持 release/包两种来源、修三处真 bug

## 一、重启不再由 update 自己 systemctl(用户口径:应该通知 guard)

原 internal/update 直接 `systemctl restart homeagent`。三个问题:

① 杀掉整个 cgroup(unit 里 KillMode=control-group)⇒ **guard 跟着死**,
   而 guard 存在的意义就是不让自己死。
② 丢失 exit 42 语义:guard 的 failures/failbackRound/failbackDone 全丢。
③ 自毁竞态:发命令的进程正是被重启杀掉的进程 ⇒ CombinedOutput 可能报错,
   **一次成功的升级被误判失败而回滚**。

现在 internal/update 只到"产物就绪":预检 → 备份 → 写 pending → 原子替换。
重启由调用方走**既有**通道(restartCh → exit 42 → guard 用新二进制重拉):

· webui      h.kernelCtl.RequestKernelRestart(本来就有,且是白名单内)
· pluginmgr  p.sdk.KernelControl().RequestKernelRestart(见下面第三条)
· CLI        外部管理命令,restart unit 后按**版本自报**核对是否真生效

## 二、OTA 支持两种来源(release 自动更新 / 指定安装包)

新增 internal/update/source.go:

	Source{Kind: FromPackage, Path}  → 直接用本地包
	Source{Kind: FromRelease}        → 查最新 Release → 下载 → 校验 digest

ParseSource(pkg, release) 统一判空规则(pkg 优先),避免三端各写一套。
两条来源解析后进**同一条** Apply,不存在两份升级实现。

## 三、修三处已提交代码里的真 bug

① **HEAD 编译不过**:d17e261 提交了 kernelmod/update/mod.go(引用 ks.Update),
   但 kernelboot/kernelstate.go 的字段改动从未提交。干净 worktree 实测
   `ks.Update undefined` —— 推上去的仓库是坏的。本次补上。

② **kernelDataDir 算错一级**:Dir(Dir("/home/newqqagent/plugins")) = "/home",
   而第 0 层 update 模块从 ks.DataDir=/home/newqqagent 读 pending
   ⇒ **agent 发起的升级一旦启动失败就永不回滚**(静默失效)。改为 Dir 一次。

③ **pluginmgr 的 KernelControl 恒为 nil**:它不在 kernelControlAllowed 里,
   于是 `if kc := p.sdk.KernelControl(); kc != nil` 永不成立 ⇒
   换完二进制**从不重启** ⇒ pending 留着 ⇒ 下次重启被判"未确认"而回滚。

   修法**不是**把 pluginmgr 加进完整白名单 —— 那等于让 LLM 能关机整个服务
   (kernel_control_gate_test 明确拦这个)。而是拆出**仅重启**授权:

	kernelControlAllowed  = {webui}       完整(含关机)
	kernelRestartAllowed  = {pluginmgr}   仅重启(shutdown 直接报错)

   两者的危险程度差一个量级:重启几秒后 guard 自己拉回来,关机不会自己回来。

## 四、EventBus:插件请求更新不必 import internal/update

新增 events.EventUpdateRequest,pluginmgr 订阅并受理:
scope=kernel → kernelUpdateApply;scope=plugin → pluginUpdate(apply)。

它在 internal/plugins/webui 的 SSE 守卫里显式登记为**内核内部控制事件**
(不该推给浏览器:前端要的是 update_progress/update_result,不是"谁发了请求")。

## 五、顺手修掉的静默失败

· webui /kernel/update 原实现:**先**写 202 Accepted,**再**解析 body,
  之后每个错误都直接 `return` —— 用户看到"已受理"然后什么都没发生。
  改为同步 Apply(失败可如实报 HTTP 状态),成功后才请求重启。
· pluginmgr 拿不到控制通道时不再假装成功,而是回报 applied=true + 明确原因
  (否则用户以为已升级,进程却还在跑旧镜像)。
· update 事件 publishResult 的 errMsg 原先是死代码(`_ = errMsg`),
  按 payload 约定写回 error 字段。
This commit is contained in:
JianFeeeee
2026-10-26 13:45:54 +08:00
parent 6dc87965ad
commit 817f6c59ae
12 changed files with 557 additions and 200 deletions

View File

@ -33,8 +33,10 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/JianFeeeee/HologramAgent/internal/update" "github.com/JianFeeeee/HologramAgent/internal/update"
) )
@ -67,6 +69,7 @@ func main() {
binPath := flag.String("bin", "/usr/local/bin/homed", "当前运行的内核二进制路径") binPath := flag.String("bin", "/usr/local/bin/homed", "当前运行的内核二进制路径")
pluginsDir := flag.String("plugins", "/home/newqqagent/plugins", "外部插件目录(探测兼容性用)") pluginsDir := flag.String("plugins", "/home/newqqagent/plugins", "外部插件目录(探测兼容性用)")
apply := flag.Bool("apply", false, "真正执行升级(默认只探测+预检)") apply := flag.Bool("apply", false, "真正执行升级(默认只探测+预检)")
unit := flag.String("unit", "homeagent", "重启用的 systemd 单元名(仅 -apply 时使用)")
flag.Parse() flag.Parse()
if *pkgPath == "" && !*release { if *pkgPath == "" && !*release {
@ -146,8 +149,50 @@ func main() {
return return
} }
// ── 6. 真正升级(备份 → 替换 → 重启 → 后置验证 → 失败回滚)── // ── 6. 真正升级:预检 → 备份 → pending → 原子替换(**不含重启**)──
if err := update.DoUpgrade(root, *binPath, newDG, *pluginsDir, upgradeDataDir(*binPath)); err != nil { out, err := update.Apply(root, *binPath, newDG, upgradeDataDir(*binPath), nil)
if err != nil {
fail("升级失败: %v", err) fail("升级失败: %v", err)
} }
fmt.Printf("\n新内核已就位:%s → %s\n备份保留在 %s\n", out.From.Version, out.To.Version, out.BackupDir)
// ── 7. 重启服务并核对版本 ──
//
// ★ 为什么 CLI 可以停下服务:它**不在**被守护的 cgroup 里,是外部管理命令,
// 与"worker 自己 systemctl restart 自杀"是两件事。
// 服务在跑时优先让它走既有重启通道(guard 重拉、不计失败轮次)——
// 那需要 WebAPI 的 api_key,这里只做最朴素的外部重启,并如实报告结果。
fmt.Printf("\n== 重启服务(unit %s)==\n", *unit)
if rerr := restartUnit(*unit); rerr != nil {
fmt.Printf("⚠️ 重启失败: %v\n", rerr)
fmt.Printf(" 新内核已在磁盘上就位,pending 标记也已写入:\n")
fmt.Printf(" · 服务下次启动时会自动确认新版本;若新版本起不来,第 0 层 update 模块会自动回滚\n")
fail("需要手动重启服务才能生效")
}
// 版本复核:等二进制自报版本变成期望值(最多 60s)。
// 用版本而不是进程状态:只有版本能证明"跑起来的确实是新的"。
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
if v, verr := update.ReadBinVersion(*binPath); verr == nil &&
v.Version == nv.Version && v.Commit == nv.Commit {
fmt.Printf("\n✅ 升级完成:%s (commit %s)\n", v.Version, update.ShortCommit(v.Commit))
return
}
time.Sleep(time.Second)
}
if v, verr := update.ReadBinVersion(*binPath); verr == nil {
fmt.Printf("⚠️ 版本复核未通过:期望 %s/%s,实际 %s/%s\n",
nv.Version, update.ShortCommit(nv.Commit), v.Version, update.ShortCommit(v.Commit))
}
fail("重启后未能确认新版本已生效(若服务起不来,下次启动会自动回滚)")
}
// restartUnit 重启 systemd 单元(外部管理动作)。
func restartUnit(unit string) error {
out, err := exec.Command("systemctl", "restart", unit).CombinedOutput()
if err != nil {
return fmt.Errorf("%v (%s)", err, strings.TrimSpace(string(out)))
}
return nil
} }

View File

@ -109,6 +109,25 @@ const (
// detail: 该步的人读说明 // detail: 该步的人读说明
EventUpdateProgress EventType = "update_progress" EventUpdateProgress EventType = "update_progress"
// EventUpdateRequest 是**其他插件请求更新**的入口。
//
// ## 为什么用事件而不是让各插件直接 import internal/update
//
// 请求方只需说"我要更新",不必知道怎么拉源码、怎么编译、怎么替换、
// 怎么回滚。那些是本子系统的事。用事件解耦后:
//
// · 请求方连 import 都不需要(外置插件更不可能 import 内核内部包)
// · 请求与进度都在总线上,WebUI 能显示"谁请求 → 到哪一步 → 结果如何"
// · 多个插件同时请求时天然按到达顺序处理,不会两个 git clone 抢同一工作目录
//
// payload 约定:
//
// scope: "kernel" | "plugin"
// name: 插件名(scope=plugin 时必填)
// pkg: 本机安装包路径(可选)
// release: bool,从仓库最新 Release 自动取包(可选)
EventUpdateRequest EventType = "update_request"
// EventUpdateResult 发布升级的最终结果(成功或失败,含回滚轨迹)。 // EventUpdateResult 发布升级的最终结果(成功或失败,含回滚轨迹)。
// //
// payload 约定: // payload 约定:

View File

@ -173,6 +173,16 @@ type KernelState struct {
MultimodalProviderName string MultimodalProviderName string
MultimodalErr string MultimodalErr string
// Update 是更新子系统的公共数据(update 模块产出)。
//
// 用接口而不是具体类型:kernelboot 不能 import internal/update
// (那会形成 kernelboot → update → kernelboot 的循环)。
// 模块在 Init 里赋 *update.State,读取方用类型断言取回。
//
// ★ 它在依赖图第 0 层(update 模块 Requires 为空),所以任何模块
// 读它时都已就绪 —— 包括"启动失败回滚"的判定结果。
Update UpdateStateProvider
// StageHost 是阶段管道(stage-host 模块产出)。 // StageHost 是阶段管道(stage-host 模块产出)。
StageHost *agentCore.StageHost StageHost *agentCore.StageHost
@ -328,3 +338,12 @@ func (s *KernelState) CloseAll() error {
} }
return nil return nil
} }
// UpdateStateProvider 是更新子系统暴露给内核的最小接口。
//
// 定义在 kernelboot 而不是 internal/update:避免 kernelboot → update 的
// import 循环(update 的模块实现要 import kernelboot)。
type UpdateStateProvider interface {
// Summary 返回给 healthcheck / 状态面的版本摘要。
Summary() map[string]interface{}
}

View File

@ -44,13 +44,36 @@ func TestKernelControlOnlyForAllowlistedPlugins(t *testing.T) {
if got := r.kernelControlFor("webui"); got == nil { if got := r.kernelControlFor("webui"); got == nil {
t.Fatal("★ webui 应拿到内核控制通道(它是有鉴权的管理界面)") t.Fatal("★ webui 应拿到内核控制通道(它是有鉴权的管理界面)")
} }
// 典型的内置插件都不该拿到 // 典型的内置插件都不该拿到**完整**通道(能关机)
for _, name := range []string{"cmd", "files", "memo", "healthcheck", "pluginmgr", "timer"} { for _, name := range []string{"cmd", "files", "memo", "healthcheck", "timer"} {
if got := r.kernelControlFor(name); got != nil { if got := r.kernelControlFor(name); got != nil {
t.Fatalf("★ 内置插件 %q 拿到了内核控制通道 —— "+ t.Fatalf("★ 内置插件 %q 拿到了内核控制通道 —— "+
"任何插件都能关机整个服务,攻击面失控", name) "任何插件都能关机整个服务,攻击面失控", name)
} }
} }
// ★★★ pluginmgr 只拿到**仅重启**通道(kernelRestartAllowed):
//
// 为什么需要它:kernel_update 已经把新二进制换好,不重启等于升级
// 永远不生效,而 pending 标记会在下次启动时被判"未确认"并回滚。
// 为什么不能给完整通道:关机后服务不会自己回来(压制 Restart=always),
// 而重启几秒后 guard 会自己拉回来 —— 两者危险程度差一个量级。
pm := r.kernelControlFor("pluginmgr")
if pm == nil {
t.Fatal("★ pluginmgr 应拿到**仅重启**通道(否则 kernel_update 换完二进制却不重启)")
}
if err := pm.RequestKernelShutdown("t"); err == nil {
t.Fatal("★★★ pluginmgr 竟然能关机整个服务 —— 攻击面失控(只该授予重启)")
}
if fake.shutdownCalls != 0 {
t.Fatalf("★ 关机请求被转发到底层通道(%d 次)—— 包装没有拦住", fake.shutdownCalls)
}
if err := pm.RequestKernelRestart("t"); err != nil {
t.Fatalf("pluginmgr 应能重启: %v", err)
}
if fake.restartCalls != 1 {
t.Fatalf("重启请求未到达底层通道(%d)", fake.restartCalls)
}
// 未知/外部插件名 // 未知/外部插件名
if got := r.kernelControlFor("some_third_party"); got != nil { if got := r.kernelControlFor("some_third_party"); got != nil {
t.Fatal("★ 未登记插件拿到了内核控制通道") t.Fatal("★ 未登记插件拿到了内核控制通道")

View File

@ -293,6 +293,36 @@ var kernelControlAllowed = map[string]bool{
"webui": true, "webui": true,
} }
// kernelRestartAllowed 是**仅重启**授权(不含关机)。
//
// ## 为什么必须把这两件事分开
//
// 完整通道的两个动作危险程度差一个量级:
//
// RequestKernelRestart guard 用磁盘上的二进制重拉 ⇒ 几秒后自己回来,**可恢复**
// RequestKernelShutdown 压制 Restart=always ⇒ 服务停住,**不会自己回来**
//
// 而 kernel_update 的收尾只需要"重拉":新二进制已经换好了,不重启等于升级
// 永远不生效 —— 更糟的是 pending 标记会在下次启动时被判"未确认"并**回滚**。
//
// ⇒ 给 pluginmgr 一个**去掉关机**的通道。攻击面因此限于"能让服务重启",
//
// 而不是"能让服务消失"。
var kernelRestartAllowed = map[string]bool{
"pluginmgr": true,
}
// restartOnly 是去掉关机能力的控制通道包装。
type restartOnly struct{ full sdk.KernelControlAPI }
func (r restartOnly) RequestKernelRestart(reason string) error {
return r.full.RequestKernelRestart(reason)
}
func (r restartOnly) RequestKernelShutdown(reason string) error {
return fmt.Errorf("该插件只被授予\"重启\"能力,未被授予关机能力(kernelRestartAllowed)")
}
// SetKernelControl 注入内核重启/关机通道(由 cmd/homed 装配时调用)。 // SetKernelControl 注入内核重启/关机通道(由 cmd/homed 装配时调用)。
// //
// ★ 为何用 setter 而不是构造参数:internal/plugin 不能 import cmd/homed // ★ 为何用 setter 而不是构造参数:internal/plugin 不能 import cmd/homed
@ -320,10 +350,16 @@ func (r *Registry) kernelControlFor(name string) sdk.KernelControlAPI {
// 拆出来的原因:buildSDK 要在**一次** RLock 里取走整组依赖(见 snapshotDeps), // 拆出来的原因:buildSDK 要在**一次** RLock 里取走整组依赖(见 snapshotDeps),
// 不能再回头单独取锁 —— RWMutex 不可重入,持锁再 RLock 在写者排队时会自锁。 // 不能再回头单独取锁 —— RWMutex 不可重入,持锁再 RLock 在写者排队时会自锁。
func kernelControlForCtl(ctl sdk.KernelControlAPI, name string) sdk.KernelControlAPI { func kernelControlForCtl(ctl sdk.KernelControlAPI, name string) sdk.KernelControlAPI {
if ctl == nil || !kernelControlAllowed[name] { if ctl == nil {
return nil return nil
} }
return ctl if kernelControlAllowed[name] {
return ctl
}
if kernelRestartAllowed[name] {
return restartOnly{full: ctl}
}
return nil
} }
func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) {

View File

@ -20,8 +20,12 @@
// ## 两阶段(用户口径) // ## 两阶段(用户口径)
// //
// apply=false(默认)→ 只查 GitHub Release 与本地版本比较,不改动任何东西 // apply=false(默认)→ 只查 GitHub Release 与本地版本比较,不改动任何东西
// apply=true → 下载/解包 → 校验 → 备份 → 原子替换 → 重启 → 版本复核 // apply=true → 解包 → 校验 → 备份 → 原子替换(**不重启**)
// → 失败自动回滚(DoUpgrade 内部完成) // → 请求 guard 重拉 → 新内核启动时由 update 模块确认或回滚
//
// ★ 重启为什么不在 internal/update 里做:worker 是被守护方,只有 guard 知道
// 这次退出该不该重拉(exit 42 → 重拉且不计失败轮次)。详见
// cmd/homed/kernel_control.go。
package pluginmgr package pluginmgr
@ -39,33 +43,37 @@ func (p *Plugin) kernelUpdateCheck() (interface{}, error) {
cur := currentKernelVersion(p) cur := currentKernelVersion(p)
info := update.CheckRelease(cur) info := update.CheckRelease(cur)
return map[string]interface{}{ return map[string]interface{}{
"apply": false, "apply": false,
"current": info.Current, "current": info.Current,
"latest": info.Latest, "latest": info.Latest,
"has_update": info.HasUpdate, "has_update": info.HasUpdate,
"asset": info.Asset, "asset": info.Asset,
"release": info.LatestURL, "release": info.LatestURL,
"reason": info.Reason, "reason": info.Reason,
"hint": "确认后传 apply=true 执行升级(会替换二进制并重启内核,连接断开数秒)", "hint": "确认后传 apply=true 执行升级(会替换二进制并重启内核,连接断开数秒)",
}, nil }, nil
} }
// kernelUpdateApply 是 kernel_update 的执行半边(apply=true)。 // kernelUpdateApply 是 kernel_update 的执行半边(apply=true)。
// //
// 只接受**本机已有的包**(pkg 参数):从 URL 现场下载需要处理 // 来源二选一(update.ParseSource):
// 校验/断点/重试,那是 CLI 的职责(homed-upgrade),agent 不应在 //
// 推理过程中发起长下载(会占满工具调用超时)。 // · pkg 非空 → 用本机已有的安装包
func (p *Plugin) kernelUpdateApply(pkgPath string) (interface{}, error) { // · release=true → 从仓库最新 GitHub Release 自动下载
if pkgPath == "" { //
return map[string]interface{}{ // ★ 顺序(此前是反的,会出真事故):
"error": "apply=true 需要 pkg(本机已存在的安装包路径;下载请跑 homed-upgrade -pkg <url>)", //
}, nil // 先 Apply(备份→pending→替换),**再**请求重启。
} // 反过来(先请求重启)会在新二进制还没就位时就重拉,
st, err := os.Stat(pkgPath) // 或重启恰好发生在替换中途 ⇒ 跑起来的是半新半旧的文件。
if err != nil || st.IsDir() { //
return map[string]interface{}{ // ★ 重启走既有的 p.sdk.KernelControl()(restartCh → exit 42 → guard 重拉)。
"error": fmt.Sprintf("pkg 无效:%s", pkgPath), // 它依赖 kernelControlAllowed 白名单 —— pluginmgr 已加入(否则该接口恒为 nil,
}, 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-*") tmp, err := os.MkdirTemp("", "kernel-upgrade-*")
@ -74,39 +82,62 @@ func (p *Plugin) kernelUpdateApply(pkgPath string) (interface{}, error) {
} }
defer os.RemoveAll(tmp) defer os.RemoveAll(tmp)
root, err := update.ExtractPkg(pkgPath, tmp) root, rel, err := src.Resolve(tmp)
if err != nil { if err != nil {
return nil, fmt.Errorf("解包: %w", err) return map[string]interface{}{"error": "取包失败: " + err.Error()}, nil
} }
newBin := update.FindFile(root, "homed") newBin := update.FindFile(root, "homed")
newDG := update.FindFile(root, "depgraph.bin") newDG := update.FindFile(root, "depgraph.bin")
if newBin == "" { if newBin == "" {
return nil, fmt.Errorf("包内找不到 homed") return map[string]interface{}{"error": "包内找不到 homed"}, nil
} }
// ★ 升级会让本进程消失:先把"已受理"回给模型,再执行替换。 // 走事件总线:升级的每一步都让 WebUI 看得到。
// var bus *events.Bus
// 否则模型看到的是工具调用超时/断线,无法区分"升级成功"和"工具坏了"。 if p.sdk != nil {
// 返回后由本 goroutine 继续跑 DoUpgrade(替换+重启+回滚)。 bus = p.sdk.EventBus()
if kc := p.sdk.KernelControl(); kc != nil {
_ = kc.RequestKernelRestart("kernel_update apply")
} }
go func() {
// 走事件总线:升级的每一步(含重启前的最后几步)都让 WebUI 看得到。 out, err := update.Apply(root, kernelBinPath(p), newDG, kernelDataDir(p), bus)
// 顺序上 publish 紧贴动作之前 —— 进程消失后事件就发不出去了。 if err != nil {
bus := (*events.Bus)(nil) return map[string]interface{}{"error": err.Error()}, nil
if p.sdk != nil { }
bus = p.sdk.EventBus()
// 产物已就位 ⇒ 走**既有**重启通道(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
} }
if err := update.DoUpgradeBus(root, kernelBinPath(p), newDG, p.pluginDir, kernelDataDir(p), bus); err != nil { } else {
// DoUpgrade 内部失败已自行回滚;这里只留一条诊断日志 // 拿不到通道时**如实报告**,不要静默当成功:否则用户以为已升级,
fmt.Fprintf(os.Stderr, "[pluginmgr] kernel_update upgrade failed (rolled back): %v\n", err) // 而进程还在跑旧镜像;pending 标记会在下次重启时被判"未确认"→ 回滚。
} return map[string]interface{}{
}() "applied": true,
return map[string]interface{}{ "error": "新内核已就位,但本插件没有被授予内核控制通道(kernelControlAllowed),无法请求重启",
"accepted": true, "hint": "手动重启服务完成升级;或改用 WebUI 的 /api/v1/kernel/update",
"message": "升级已受理:正在替换二进制并重启。完成后用 kernel_update(apply=false)确认新版本。", }, nil
}, 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 推导数据目录(升级确认标记的落点)。 // kernelDataDir 推导数据目录(升级确认标记的落点)。
@ -114,8 +145,14 @@ func (p *Plugin) kernelUpdateApply(pkgPath string) (interface{}, error) {
// 从 pluginDir 反推:<dataDir>/plugins ⇒ 上一级即 dataDir。 // 从 pluginDir 反推:<dataDir>/plugins ⇒ 上一级即 dataDir。
// 与内核 --data 的值一致,update 模块(第 0 层)才能读到同一个标记。 // 与内核 --data 的值一致,update 模块(第 0 层)才能读到同一个标记。
func kernelDataDir(p *Plugin) string { 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 发起的升级一旦启动失败就**不会回滚**(静默失效)。
if p.pluginDir != "" { if p.pluginDir != "" {
return filepath.Dir(filepath.Dir(p.pluginDir)) return filepath.Dir(p.pluginDir)
} }
return "/home/newqqagent" return "/home/newqqagent"
} }
@ -142,3 +179,47 @@ func kernelBinPath(p *Plugin) string {
} }
return "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)
}
}()
}

View File

@ -70,27 +70,31 @@ func TestKernelUpdate_CheckShape(t *testing.T) {
} }
} }
// 判据 4:kernel_update apply 缺 pkg ⇒ 明确报错并指向 CLI。 // 判据 4:kernel_update apply 没给来源 ⇒ 明确报错(不许"猜一个默认动作")。
func TestKernelUpdate_ApplyRequiresPkg(t *testing.T) { //
// 旧判据要求错误里出现 homed-upgrade —— 那是"agent 不该在推理中发起长下载"
// 时期的限制。现在支持 release=true 自动取包(下载放在内核侧统一实现),
// 所以判据改为:必须说清"两种来源二选一"。
func TestKernelUpdate_ApplyRequiresSource(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()} p := &Plugin{pluginDir: t.TempDir()}
out, err := p.kernelUpdateApply("") out, err := p.kernelUpdateApply("", false)
if err != nil { if err != nil {
t.Fatalf("应返回可呈现的错误: %v", err) t.Fatalf("应返回可呈现的错误: %v", err)
} }
m := out.(map[string]interface{}) m := out.(map[string]interface{})
if !strings.Contains(fmtSprint(m["error"]), "pkg") { msg := fmtSprint(m["error"])
t.Errorf("错误应说明需要 pkg,实际: %v", m["error"]) if !strings.Contains(msg, "pkg") {
t.Errorf("错误应说明需要 pkg,实际: %v", msg)
} }
// 并应提示下载走 CLI(agent 不该在推理中发起长下载) if !strings.Contains(msg, "Release") && !strings.Contains(msg, "release") {
if !strings.Contains(fmtSprint(m["error"]), "homed-upgrade") { t.Errorf("错误应说明可从 Release 自动更新,实际: %v", msg)
t.Errorf("错误应指向 homed-upgrade,实际: %v", m["error"])
} }
} }
// 判据 5:kernel_update apply 的 pkg 是目录/不存在 ⇒ 拒绝。 // 判据 5:kernel_update apply 的 pkg 是目录/不存在 ⇒ 拒绝。
func TestKernelUpdate_ApplyRejectsBadPkg(t *testing.T) { func TestKernelUpdate_ApplyRejectsBadPkg(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()} p := &Plugin{pluginDir: t.TempDir()}
out, _ := p.kernelUpdateApply(t.TempDir()) // 目录不是包 out, _ := p.kernelUpdateApply(t.TempDir(), false) // 目录不是包
m := out.(map[string]interface{}) m := out.(map[string]interface{})
if _, hasErr := m["error"]; !hasErr { if _, hasErr := m["error"]; !hasErr {
t.Errorf("目录不应被当作安装包,实际: %v", m) t.Errorf("目录不应被当作安装包,实际: %v", m)

View File

@ -21,9 +21,10 @@ import (
"sync" "sync"
"time" "time"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/plugin" "github.com/JianFeeeee/HologramAgent/internal/plugin"
"github.com/JianFeeeee/HologramAgent/internal/update"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/internal/update"
) )
// platformBinary 按当前 OS/ARCH 选择正确的插件二进制文件名。 // platformBinary 按当前 OS/ARCH 选择正确的插件二进制文件名。
@ -97,6 +98,9 @@ type Plugin struct {
httpAddr string // 本实例的监听地址(默认 defaultHTTPAddr;来自 settings) httpAddr string // 本实例的监听地址(默认 defaultHTTPAddr;来自 settings)
sdk *sdk.PluginSDK sdk *sdk.PluginSDK
pluginDir string pluginDir string
// unsubUpdateReq 注销 update_request 订阅(Stop 时调用)。
unsubUpdateReq func()
// maxPackageBytes 是单个插件包 **压缩后** 的上限(0 = 不限)。 // maxPackageBytes 是单个插件包 **压缩后** 的上限(0 = 不限)。
// //
// 为什么是配置项而不是常量:内嵌运行时(node+pi 之类)就要 ~100MB, // 为什么是配置项而不是常量:内嵌运行时(node+pi 之类)就要 ~100MB,
@ -495,12 +499,26 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
return p.pluginInfo(name) return p.pluginInfo(name)
}) })
// ── 订阅:其他插件的更新请求(EventBus,避免它们直接 import internal/update)──
//
// 为什么用事件而不是让插件调函数:
// · 请求方只说"我要更新",不必知道怎么拉源码/编译/替换/回滚
// · 外置插件根本 import 不到内核内部包
// · 请求与进度都在总线上,WebUI 能显示"谁请求 → 到哪步 → 结果如何"
//
// 注意:内核升级的**重启**仍走既有 KernelControlAPI(restartCh → exit 42
// → guard 重拉),本订阅只负责"受理请求 + 执行替换"。
if bus := s.EventBus(); bus != nil {
p.unsubUpdateReq = bus.Subscribe(events.EventUpdateRequest, p.onUpdateRequest)
}
s.RegisterTool("kernel_update", sdk.ToolDef{ s.RegisterTool("kernel_update", sdk.ToolDef{
Name: "kernel_update", Name: "kernel_update",
Description: "检查/升级**内核**(homed 二进制)。两阶段:默认(apply=false)只查 GitHub Release " + Description: "检查/升级**内核**(homed 二进制)。两阶段:默认(apply=false)只查 GitHub Release " +
"与本地版本比较,不改动任何东西;看过结果后传 apply=true + pkg(本机已存在的安装包路径)" + "与本地版本比较,不改动任何东西;看过结果后传 apply=true,并给出包来源二选一:" +
"真正升级:校验 → 备份 → 原子替换 → 重启 → 版本复核 → 失败自动回滚。" + "pkg=本机安装包路径,或 release=true 从最新 GitHub Release 自动下载。" +
"升级会让内核重启,连接断开数秒。从 URL 下载新包请用 CLI 的 homed-upgrade。", "升级流程:校验 → 备份 → 原子替换 → 由 guard 重拉 → 新内核启动时确认,失败自动回滚。" +
"升级会让内核重启,连接断开数秒。",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
@ -510,7 +528,11 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
}, },
"pkg": map[string]interface{}{ "pkg": map[string]interface{}{
"type": "string", "type": "string",
"description": "安装包在本机的路径(.tar.gz)。仅 apply=true 时需要", "description": "安装包在本机的路径(.tar.gz)。与 release 二选一;给了 pkg 就优先用它",
},
"release": map[string]interface{}{
"type": "boolean",
"description": "true=从仓库最新 GitHub Release 自动下载安装包。未给 pkg 时可用",
}, },
}, },
}, },
@ -519,8 +541,9 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
}, func(args map[string]interface{}) (interface{}, error) { }, func(args map[string]interface{}) (interface{}, error) {
apply, _ := args["apply"].(bool) apply, _ := args["apply"].(bool)
pkgPath, _ := args["pkg"].(string) pkgPath, _ := args["pkg"].(string)
release, _ := args["release"].(bool)
if apply { if apply {
return p.kernelUpdateApply(pkgPath) return p.kernelUpdateApply(pkgPath, release)
} }
return p.kernelUpdateCheck() return p.kernelUpdateCheck()
}) })
@ -732,7 +755,6 @@ func (p *Plugin) pluginUpdate(apply bool, scope string) (interface{}, error) {
}, nil }, nil
} }
// ======== HTTP API ======== // ======== HTTP API ========
func (p *Plugin) startHTTPServer() { func (p *Plugin) startHTTPServer() {

View File

@ -156,6 +156,13 @@ func TestEveryPublishedEventHasAnOutwardPath(t *testing.T) {
// 插件间握手事件:clawhubadapter → skillmgr 移交 SKILL 条目, // 插件间握手事件:clawhubadapter → skillmgr 移交 SKILL 条目,
// 属内核内部的职责链,前端无需感知。 // 属内核内部的职责链,前端无需感知。
"skill_detected": "插件间握手(clawhubadapter → skillmgr),非对话流程", "skill_detected": "插件间握手(clawhubadapter → skillmgr),非对话流程",
// update_request 是**内核内部的控制事件**(插件 → pluginmgr):
// "我要更新",由 pluginmgr 的订阅者受理并执行替换。
//
// 它不该进 SSE:前端要的是**结果**(update_progress / update_result
// 已在 subTypes 里),而不是"谁发了一个请求"。把它推给浏览器只会
// 让界面多出一条没有下文的消息。
"update_request": "插件间请求更新(pluginmgr 订阅处理),非 UI 通知",
// tools_ready:全部插件加载完毕、工具注册齐的信号,供内核侧订阅者 // tools_ready:全部插件加载完毕、工具注册齐的信号,供内核侧订阅者
// (如 seq 的开机序列)用。它是**内核内部**的就绪信号, // (如 seq 的开机序列)用。它是**内核内部**的就绪信号,
// 不是对话流程事件,前端无需感知。 // 不是对话流程事件,前端无需感知。

View File

@ -2,23 +2,36 @@ package webui
// 内核更新的 HTTP 端点(GET 检查 / POST 升级)。 // 内核更新的 HTTP 端点(GET 检查 / POST 升级)。
// //
// ## 与 agent 工具同底层的架构约束 // ## 三端共享同一底层(架构约束)
// //
// agent 的 kernel_update 工具、本端点、CLI(homed-upgrade)三者共享 // agent 的 kernel_update 工具、本端点、CLI(homed-upgrade)三者共享
// internal/update 的 CheckRelease / DoUpgrade —— 三份实现必然漂移, // internal/update 的 CheckRelease / Apply —— 三份实现必然漂移,会出现
// 会出现"WebUI 说有新版、agent 说没有"的分裂状态。 // "WebUI 说有新版、agent 说没有"的分裂状态。
// 本文件只做 HTTP 形状转换,不含业务逻辑。 // 本文件只做 HTTP 形状转换,不含业务逻辑。
// //
// ## 与 kernel/restart 的关系 // ## 包来源二选一
// //
// 升级 = 换二进制 + 重启。重启通道(kernelCtl)已存在且经过生产验证, // {"release": true} → 从仓库最新 GitHub Release 自动下载
// 升级走同一通道触发重启,不自建第二条。 // {"pkg": "/path/to/pkg.tar.gz"} → 用本机已有的安装包(优先)
//
// ## 升级 = 换产物 + 重启,两件事**分开做**
//
// Apply(本进程内,同步) :校验 → 备份 → pending → 原子替换
// 重启(交给内核控制通道) :kernelCtl.RequestKernelRestart → exit 42
// → guard 用新二进制重拉(不计失败轮次)
//
// ★ 为什么 Apply 必须同步返回:替换是**可失败**的动作(空间不足、权限、
// 包损坏)。异步的话这些错误全被吞掉,用户只看到"已受理"然后毫无变化。
// 而重启可以异步 —— 它让本进程消失,本来就没法回给它结果。
//
// ★ 为什么不用 systemctl restart:见 cmd/homed/kernel_control.go ——
// worker 是被守护方,只有 guard 知道这次退出该不该重拉。
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"os" "os"
"os/exec"
"github.com/JianFeeeee/HologramAgent/internal/events" "github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/update" "github.com/JianFeeeee/HologramAgent/internal/update"
@ -38,45 +51,85 @@ func (h *Handler) handleKernelUpdate(w http.ResponseWriter, r *http.Request) {
// handleKernelUpdateCheck 处理 GET —— 只检查不改动。 // handleKernelUpdateCheck 处理 GET —— 只检查不改动。
func (h *Handler) handleKernelUpdateCheck(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleKernelUpdateCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { writeJSON(w, http.StatusOK, update.CheckRelease(currentKernelVersion()))
writeJSONError(w, http.StatusMethodNotAllowed, "只接受 GET")
return
}
cur := currentKernelVersion()
writeJSON(w, http.StatusOK, update.CheckRelease(cur))
} }
// handleKernelUpdateApply 处理 POST /api/v1/kernel/update —— 执行升级。 // handleKernelUpdateApply 处理 POST /api/v1/kernel/update —— 执行升级。
//
// body: {"pkg": "/path/to/pkg.tar.gz"}(可选;缺省用最近一次检查到的
// release 资产 URL 现场下载)。
func (h *Handler) handleKernelUpdateApply(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleKernelUpdateApply(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { var body struct {
writeJSONError(w, http.StatusMethodNotAllowed, "只接受 POST") Pkg string `json:"pkg"`
Release bool `json:"release"`
}
// body 可为空(不合法就在下面报"必须指定来源"),所以解码失败不单独报错。
_ = json.NewDecoder(r.Body).Decode(&body)
src, err := update.ParseSource(body.Pkg, body.Release)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return return
} }
// ★ 升级会让进程重启:先把响应写回去(同 kernel/restart 的教训)
writeJSON(w, http.StatusAccepted, map[string]interface{}{
"accepted": true,
"message": "升级已受理。内核将替换二进制并重启,连接会断开数秒。完成后刷新状态页确认新版本。",
})
var body struct { binPath := update.KernelBinaryPath()
Pkg string `json:"pkg"`
tmp, err := os.MkdirTemp("", "kernel-upgrade-*")
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "建临时目录失败: "+err.Error())
return
} }
_ = json.NewDecoder(r.Body).Decode(&body) defer os.RemoveAll(tmp)
root, rel, err := src.Resolve(tmp)
if err != nil {
writeJSONError(w, http.StatusBadGateway, "取包失败: "+err.Error())
return
}
newDG := update.FindFile(root, "depgraph.bin")
// WebUI 侧天然有总线(它就是 SSE 的上游):升级每一步都让界面看得到 // WebUI 侧天然有总线(它就是 SSE 的上游):升级每一步都让界面看得到
var bus *events.Bus var bus *events.Bus
if h.sdk != nil { if h.sdk != nil {
bus = h.sdk.EventBus() bus = h.sdk.EventBus()
} }
go runKernelUpgrade(body.Pkg, bus)
out, err := update.Apply(root, binPath, newDG, dataDirOf(binPath), bus)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
res := map[string]interface{}{
"applied": true,
"from": out.From.Version,
"to": out.To.Version,
"backup": out.BackupDir,
"message": "新内核已就位,正在重启。新版本启动后由第 0 层 update 模块确认;无法启动会自动回滚。",
}
if rel != nil {
res["release"] = rel.Latest
res["asset"] = rel.Asset
}
// 先把响应写回,再让内核消失(同 kernel/restart 的教训:不 Flush 会
// 变成 connection reset,用户以为出错了)。
writeJSON(w, http.StatusOK, res)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// 重启走**既有**内核控制通道(restartCh → exit 42 → guard 用新二进制重拉)。
// 不自己 systemctl,也不另开通道。
if h.kernelCtl == nil {
log.Printf("[webui] 新内核已就位,但内核控制通道不可用(kernelControlAllowed 未授予 webui),无法请求重启")
return
}
if err := h.kernelCtl.RequestKernelRestart("kernel_update apply"); err != nil {
log.Printf("[webui] kernel restart request failed (新内核已就位,需手动重启): %v", err)
}
} }
// currentKernelVersion 读本机 homed 的版本(尽力而为)。 // currentKernelVersion 读本机 homed 的版本(尽力而为)。
func currentKernelVersion() *update.BinVersion { func currentKernelVersion() *update.BinVersion {
bin := "/usr/local/bin/homed" bin := update.KernelBinaryPath()
if _, err := os.Stat(bin); err != nil { if _, err := os.Stat(bin); err != nil {
return nil return nil
} }
@ -87,33 +140,13 @@ func currentKernelVersion() *update.BinVersion {
return v return v
} }
// runKernelUpgrade 在后台执行完整升级闭环(含重启与回滚)。 // dataDirOf 推导数据目录(升级确认标记的落点)。
// //
// bus 为 nil 时退化成纯日志(该部署未接事件总线)。 // 与内核 `--data` 必须一致,否则第 0 层 update 模块读不到 pending 标记。
func runKernelUpgrade(pkgPath string, bus *events.Bus) { // 优先用环境变量显式指定(部署方知道自己在哪),否则退回本机生产路径。
if pkgPath == "" { func dataDirOf(binPath string) string {
// 没给本地包:从最新 release 资产下载。 if d := os.Getenv("HOMEAGENT_DATA_DIR"); d != "" {
// 下载与升级的完整闭环由 CLI 工具实现,这里复用同一二进制 return d
// (/usr/local/bin/homed-upgrade 与本进程同源构建,语义一致)。
if _, err := exec.LookPath("homed-upgrade"); err != nil {
return
}
return // 下载逻辑见 CLI;此处仅受理本地包路径
} }
// 本地包:直接走共享底层(与 CLI 同代码路径) return "/home/newqqagent"
tmp, err := os.MkdirTemp("", "kernel-upgrade-*")
if err != nil {
return
}
defer os.RemoveAll(tmp)
root, err := update.ExtractPkg(pkgPath, tmp)
if err != nil {
return
}
newBin := update.FindFile(root, "homed")
newDG := update.FindFile(root, "depgraph.bin")
if newBin == "" {
return
}
_ = update.DoUpgradeBus(root, "/usr/local/bin/homed", newDG, "/home/newqqagent/plugins", "/home/newqqagent", bus)
} }

View File

@ -246,31 +246,36 @@ func FileSize(p string) int64 {
return 0 return 0
} }
// doUpgrade 执行真实升级:备份 → 预检 → 原子替换 → 重启 → 后置验证 → 失败回滚。 // Apply 执行真实升级:预检 → 备份 → 写 pending → 原子替换。
// //
// 每一步失败都往回退,且**回滚后必须能正常启动**——这是本函数的核心承诺: // ## ★ 本函数**不重启**内核
// 宁可升级失败,不可让机器起不来。
func DoUpgrade(root, binPath, newDG, pluginsDir, dataDir string) error {
return DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir, nil)
}
// DoUpgradeBus 同 DoUpgrade,但把每个步骤发布到事件总线。
// //
// ★ 为什么必须"先发事件再动作":升级的最后几步会让进程消失 // 重启必须由**调用方**通过内核控制通道发起,理由见
// (replace → restart)。顺序一反,最后几步的事件永远发不出去 —— // cmd/homed/kernel_control.go 的说明:worker 是被守护方,只有 guard
// 而那时用户最需要知道"升级到哪一步了/失败了/已回滚"。 // 知道这次退出该不该重拉(exit 42 → 重拉且不计失败轮次)。
// //
// bus 为 nil 时退化成纯 printf(CLI 场景没有总线)。 // 本包若自己调 `systemctl restart`,会带来三个问题:
func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events.Bus) error { //
// · 杀掉整个 cgroup(含 guard)—— 把监督者一起杀掉
// · 丢失 exit 42 语义:guard 的 failures/failbackRound 状态全丢
// · 发命令的进程正是被重启杀掉的进程 ⇒ 返回值不可靠,
// 一次成功的升级会被误判成失败而触发回滚
//
// 所以这里只承诺到"磁盘上的产物已就绪,pending 标记已写"。
// 后续重启 → 新内核启动 → update 模块(第 0 层)确认或回滚。
//
// 每一步失败都往回退:替换中途失败 ⇒ 立即用备份还原并清标记
// (此时尚未重启,回滚在本进程内完成,机器始终可启动)。
func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, error) {
newBin := FindFile(root, "homed") newBin := FindFile(root, "homed")
if newBin == "" { if newBin == "" {
return fmt.Errorf("包内无 homed") return nil, fmt.Errorf("包内无 homed")
} }
// ── 预检:新二进制必须能自报版本(说明它是可执行的、架构对的)── // ── 预检:新二进制必须能自报版本(说明它是可执行的、架构对的)──
nv, err := ReadBinVersion(newBin) nv, err := ReadBinVersion(newBin)
if err != nil { if err != nil {
return fmt.Errorf("预检失败,新二进制不可执行: %w", err) return nil, fmt.Errorf("预检失败,新二进制不可执行: %w", err)
} }
publishProgress(bus, "kernel", "", "verify", "新内核可执行,版本 "+nv.Version) publishProgress(bus, "kernel", "", "verify", "新内核可执行,版本 "+nv.Version)
fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version) fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version)
@ -279,7 +284,7 @@ func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events.
ts := NowStamp() ts := NowStamp()
backup := filepath.Join("/root", "homed-upgrade-backup-"+ts) backup := filepath.Join("/root", "homed-upgrade-backup-"+ts)
if err := os.MkdirAll(backup, 0755); err != nil { if err := os.MkdirAll(backup, 0755); err != nil {
return fmt.Errorf("建备份目录: %w", err) return nil, fmt.Errorf("建备份目录: %w", err)
} }
targets := []string{binPath} targets := []string{binPath}
if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); FileExists(dg) { if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); FileExists(dg) {
@ -287,7 +292,7 @@ func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events.
} }
for _, t := range targets { for _, t := range targets {
if err := CopyFile(t, filepath.Join(backup, filepath.Base(t))); err != nil { if err := CopyFile(t, filepath.Join(backup, filepath.Base(t))); err != nil {
return fmt.Errorf("备份 %s: %w", t, err) return nil, fmt.Errorf("备份 %s: %w", t, err)
} }
} }
publishProgress(bus, "kernel", "", "backup", fmt.Sprintf("已备份 %d 个文件", len(targets))) publishProgress(bus, "kernel", "", "backup", fmt.Sprintf("已备份 %d 个文件", len(targets)))
@ -297,9 +302,6 @@ func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events.
// //
// 顺序很关键:先写标记再替换。若反过来(替换后才写),"替换成功但 // 顺序很关键:先写标记再替换。若反过来(替换后才写),"替换成功但
// 没来得及写标记就崩了"会丢标记 ⇒ 下次启动不知道该回滚。 // 没来得及写标记就崩了"会丢标记 ⇒ 下次启动不知道该回滚。
//
// 标记由 update 模块(第 0 层)在下次启动时读;读到就说明上次升级
// 留下的版本没能活到清除标记 ⇒ 回滚(见 internal/update/rollback.go)。
curVer := &BinVersion{} curVer := &BinVersion{}
if v, verr := ReadBinVersion(binPath); verr == nil { if v, verr := ReadBinVersion(binPath); verr == nil {
curVer = v curVer = v
@ -314,14 +316,15 @@ func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events.
}); perr != nil { }); perr != nil {
// 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力, // 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力,
// 而不带回滚的升级等于赌运气。 // 而不带回滚的升级等于赌运气。
return fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr) return nil, fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr)
} }
fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir)) fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir))
} }
rollback := func(cause error) error { // restore 在替换失败时把已动的文件还原(尚未重启,机器仍可启动)。
restore := func(cause error) (*Outcome, error) {
publishProgress(bus, "kernel", "", "rollback", cause.Error()) publishProgress(bus, "kernel", "", "rollback", cause.Error())
fmt.Printf("\n❌ %v\n== 开始回滚 ==\n", cause) fmt.Printf("\n❌ %v\n== 就地还原(尚未重启,机器仍可启动)==\n", cause)
if dataDir != "" { if dataDir != "" {
if cerr := ClearPendingUpgrade(dataDir); cerr != nil { if cerr := ClearPendingUpgrade(dataDir); cerr != nil {
fmt.Printf(" ⚠️ 清除升级确认标记失败: %v\n", cerr) fmt.Printf(" ⚠️ 清除升级确认标记失败: %v\n", cerr)
@ -330,74 +333,41 @@ func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events.
for _, t := range targets { for _, t := range targets {
src := filepath.Join(backup, filepath.Base(t)) src := filepath.Join(backup, filepath.Base(t))
if err := CopyFile(src, t); err != nil { if err := CopyFile(src, t); err != nil {
fmt.Printf(" ⚠️ 回滚 %s 失败: %v(需人工处理)\n", t, err) fmt.Printf(" ⚠️ 还原 %s 失败: %v(需人工处理)\n", t, err)
continue continue
} }
fmt.Printf(" 已恢复 %s\n", t) fmt.Printf(" 已恢复 %s\n", t)
} }
if err := restartService(); err != nil { publishResult(bus, "kernel", "", false, curVer.Version, nv.Version, true, cause.Error())
fmt.Printf(" ⚠️ 重启失败: %v(需人工处理)\n", err) return nil, fmt.Errorf("替换失败已还原(未重启): %w", cause)
} else if !WaitHealthy(60) {
fmt.Println(" ⚠️ 回滚后仍不健康,需人工介入")
} else {
fmt.Println(" ✅ 已回滚并恢复正常运行")
}
return fmt.Errorf("升级失败已回滚: %w", cause)
} }
// ── 替换(depgraph 必须与 homed 同批,顺序无关但都要成)── // ── 替换(depgraph 必须与 homed 同批,顺序无关但都要成)──
if newDG != "" { if newDG != "" {
dst := filepath.Join(filepath.Dir(binPath), "depgraph.bin") dst := filepath.Join(filepath.Dir(binPath), "depgraph.bin")
if err := CopyFile(newDG, dst); err != nil { if err := CopyFile(newDG, dst); err != nil {
return rollback(fmt.Errorf("替换 depgraph.bin: %w", err)) return restore(fmt.Errorf("替换 depgraph.bin: %w", err))
} }
fmt.Println("已替换 depgraph.bin") fmt.Println("已替换 depgraph.bin")
} }
if err := CopyFile(newBin, binPath); err != nil { if err := CopyFile(newBin, binPath); err != nil {
return rollback(fmt.Errorf("替换 homed: %w", err)) return restore(fmt.Errorf("替换 homed: %w", err))
} }
fmt.Println("已替换 homed") fmt.Println("已替换 homed")
publishProgress(bus, "kernel", "", "ready", "新内核已就位,等待重启")
// ── 重启 + 后置验证 ── return &Outcome{
if err := restartService(); err != nil { From: curVer,
return rollback(fmt.Errorf("重启: %w", err)) To: nv,
} BackupDir: backup,
if !WaitHealthy(90) { }, nil
return rollback(fmt.Errorf("新版本未在超时内达到健康状态"))
}
// 版本复核:确认跑起来的**确实**是新版本(而不是重启失败后旧的还在)
got, err := ReadBinVersion(binPath)
if err != nil || got.Version != nv.Version || got.Commit != nv.Commit {
return rollback(fmt.Errorf("版本复核不符:期望 %s/%s,实际 %v", nv.Version, ShortCommit(nv.Commit), got))
}
publishResult(bus, "kernel", "", true, curVer.Version, got.Version, false, "")
fmt.Printf("\n✅ 升级完成:%s (commit %s)\n", got.Version, ShortCommit(got.Commit))
fmt.Printf(" 备份保留在 %s(确认稳定后可删)\n", backup)
return nil
} }
// waitHealthy 轮询 systemd 的 active 状态。 // Outcome 是一次替换的结果(**不含重启**,重启由调用方走内核控制通道)。
func WaitHealthy(seconds int) bool { type Outcome struct {
for i := 0; i < seconds; i++ { From *BinVersion // 升级前版本(回滚目标)
out, err := exec.Command("systemctl", "is-active", "homeagent").Output() To *BinVersion // 已就位的新版本
if err == nil && strings.TrimSpace(string(out)) == "active" { BackupDir string // 备份目录(确认稳定后可删)
// active 还不够:给它几秒让插件真正加载完再判一次
if i > 3 {
return true
}
}
sleepSecond()
}
return false
}
func restartService() error {
out, err := exec.Command("systemctl", "restart", "homeagent").CombinedOutput()
if err != nil {
return fmt.Errorf("%v (%s)", err, strings.TrimSpace(string(out)))
}
return nil
} }
func CopyFile(src, dst string) error { func CopyFile(src, dst string) error {
@ -437,9 +407,6 @@ func NowStamp() string {
return time.Now().Format("20060102-150405") return time.Now().Format("20060102-150405")
} }
// sleepSecond 隔一秒(抽出来便于将来换成可注入的等待,测试不必真睡)。
func sleepSecond() { time.Sleep(time.Second) }
// inferVersionFromRunning 在老内核不支持 --version 时,尽力推断当前版本。 // inferVersionFromRunning 在老内核不支持 --version 时,尽力推断当前版本。
// //
// 手段按可靠性排序,任一成功即返回: // 手段按可靠性排序,任一成功即返回:

101
internal/update/source.go Normal file
View File

@ -0,0 +1,101 @@
package update
// OTA 的**来源**解析:从 Release 自动更新,或指定本地安装包。
//
// ## 为什么把"来源"做成一个显式类型
//
// 两种来源的后半段完全一致(解包 → 预检 → 备份 → 替换 → pending),
// 只有"包从哪来"不同。若在各入口各写一遍 if release { 下载 } else { 用路径 },
// 就会重演"三端三份实现慢慢漂移"的老问题。
//
// 所以:**来源解析一次,升级流程只有一条**。
//
// Source{Kind: FromRelease} → 查 GitHub Release → 下载 → 解包
// Source{Kind: FromPackage, Path} → 直接用本地包 → 解包
// ↓
// Apply() 同一条路
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// SourceKind 是 OTA 包的来源类别。
type SourceKind string
const (
// FromPackage 表示"用户直接给了安装包路径"。
//
// 为什么保留它:离线机器、内网部署、灰度验证都需要先自己拿到包再升级;
// 而且它是**可复现**的(同一个文件升同一台机器)。
FromPackage SourceKind = "package"
// FromRelease 表示"从仓库最新 GitHub Release 自动取包"。
//
// 适用场景:常规运维升级,不想手工下载。
// ★ 拿到的是**最新 Release**,不一定是用户以为的那个版本 ——
// 所以 Resolve 会把 release tag 一并回报,让调用方打印出来。
FromRelease SourceKind = "release"
)
// Source 是一次 OTA 的包来源。
type Source struct {
Kind SourceKind
// Path 是本地安装包路径(仅 Kind=FromPackage 时有意义)。
Path string
}
// ParseSource 从参数解析来源(CLI/WebAPI 共用,避免各写一套判空逻辑)。
//
// 规则:
//
// pkg 非空 → 本地包(**优先**:显式给的路径比"自动取最新"更明确)
// pkg 为空 + release → 从 Release 取
// 两者都空 → 报错(不允许"猜一个默认动作")
func ParseSource(pkg string, release bool) (Source, error) {
pkg = strings.TrimSpace(pkg)
switch {
case pkg != "":
return Source{Kind: FromPackage, Path: pkg}, nil
case release:
return Source{Kind: FromRelease}, nil
default:
return Source{}, fmt.Errorf("必须指定安装包路径(pkg),或选择从 Release 自动更新(release)")
}
}
// Resolve 把来源解析成"解包后的根目录",供 Apply 使用。
//
// workDir 是调用方提供的临时目录(由调用方负责清理)。
// 返回的 rel 仅在 Kind=FromRelease 时非 nil(携带用的是哪个 tag/资产)。
func (s Source) Resolve(workDir string) (root string, rel *ReleaseInfo, err error) {
switch s.Kind {
case FromPackage:
if s.Path == "" {
return "", nil, fmt.Errorf("来源是本地包,但路径为空")
}
st, serr := os.Stat(s.Path)
if serr != nil {
return "", nil, fmt.Errorf("安装包不存在: %s(%v)", s.Path, serr)
}
if st.IsDir() {
return "", nil, fmt.Errorf("安装包是目录,不是文件: %s", s.Path)
}
root, err = ExtractPkg(s.Path, workDir)
return root, nil, err
case FromRelease:
pkgPath := filepath.Join(workDir, "release.tar.gz")
info, derr := DownloadLatestRelease(pkgPath)
if derr != nil {
return "", info, derr
}
root, err = ExtractPkg(pkgPath, workDir)
return root, info, err
default:
return "", nil, fmt.Errorf("未知的 OTA 来源: %q", s.Kind)
}
}