mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
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:
@ -33,8 +33,10 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/update"
|
||||
)
|
||||
@ -67,6 +69,7 @@ func main() {
|
||||
binPath := flag.String("bin", "/usr/local/bin/homed", "当前运行的内核二进制路径")
|
||||
pluginsDir := flag.String("plugins", "/home/newqqagent/plugins", "外部插件目录(探测兼容性用)")
|
||||
apply := flag.Bool("apply", false, "真正执行升级(默认只探测+预检)")
|
||||
unit := flag.String("unit", "homeagent", "重启用的 systemd 单元名(仅 -apply 时使用)")
|
||||
flag.Parse()
|
||||
|
||||
if *pkgPath == "" && !*release {
|
||||
@ -146,8 +149,50 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
// ── 6. 真正升级(备份 → 替换 → 重启 → 后置验证 → 失败回滚)──
|
||||
if err := update.DoUpgrade(root, *binPath, newDG, *pluginsDir, upgradeDataDir(*binPath)); err != nil {
|
||||
// ── 6. 真正升级:预检 → 备份 → pending → 原子替换(**不含重启**)──
|
||||
out, err := update.Apply(root, *binPath, newDG, upgradeDataDir(*binPath), nil)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@ -109,6 +109,25 @@ const (
|
||||
// detail: 该步的人读说明
|
||||
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 发布升级的最终结果(成功或失败,含回滚轨迹)。
|
||||
//
|
||||
// payload 约定:
|
||||
|
||||
@ -173,6 +173,16 @@ type KernelState struct {
|
||||
MultimodalProviderName string
|
||||
MultimodalErr string
|
||||
|
||||
// Update 是更新子系统的公共数据(update 模块产出)。
|
||||
//
|
||||
// 用接口而不是具体类型:kernelboot 不能 import internal/update
|
||||
// (那会形成 kernelboot → update → kernelboot 的循环)。
|
||||
// 模块在 Init 里赋 *update.State,读取方用类型断言取回。
|
||||
//
|
||||
// ★ 它在依赖图第 0 层(update 模块 Requires 为空),所以任何模块
|
||||
// 读它时都已就绪 —— 包括"启动失败回滚"的判定结果。
|
||||
Update UpdateStateProvider
|
||||
|
||||
// StageHost 是阶段管道(stage-host 模块产出)。
|
||||
StageHost *agentCore.StageHost
|
||||
|
||||
@ -328,3 +338,12 @@ func (s *KernelState) CloseAll() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStateProvider 是更新子系统暴露给内核的最小接口。
|
||||
//
|
||||
// 定义在 kernelboot 而不是 internal/update:避免 kernelboot → update 的
|
||||
// import 循环(update 的模块实现要 import kernelboot)。
|
||||
type UpdateStateProvider interface {
|
||||
// Summary 返回给 healthcheck / 状态面的版本摘要。
|
||||
Summary() map[string]interface{}
|
||||
}
|
||||
|
||||
@ -44,13 +44,36 @@ func TestKernelControlOnlyForAllowlistedPlugins(t *testing.T) {
|
||||
if got := r.kernelControlFor("webui"); got == nil {
|
||||
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 {
|
||||
t.Fatalf("★ 内置插件 %q 拿到了内核控制通道 —— "+
|
||||
"任何插件都能关机整个服务,攻击面失控", 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 {
|
||||
t.Fatal("★ 未登记插件拿到了内核控制通道")
|
||||
|
||||
@ -293,6 +293,36 @@ var kernelControlAllowed = map[string]bool{
|
||||
"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 装配时调用)。
|
||||
//
|
||||
// ★ 为何用 setter 而不是构造参数:internal/plugin 不能 import cmd/homed
|
||||
@ -320,10 +350,16 @@ func (r *Registry) kernelControlFor(name string) sdk.KernelControlAPI {
|
||||
// 拆出来的原因:buildSDK 要在**一次** RLock 里取走整组依赖(见 snapshotDeps),
|
||||
// 不能再回头单独取锁 —— RWMutex 不可重入,持锁再 RLock 在写者排队时会自锁。
|
||||
func kernelControlForCtl(ctl sdk.KernelControlAPI, name string) sdk.KernelControlAPI {
|
||||
if ctl == nil || !kernelControlAllowed[name] {
|
||||
if ctl == 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) {
|
||||
|
||||
@ -20,8 +20,12 @@
|
||||
// ## 两阶段(用户口径)
|
||||
//
|
||||
// apply=false(默认)→ 只查 GitHub Release 与本地版本比较,不改动任何东西
|
||||
// apply=true → 下载/解包 → 校验 → 备份 → 原子替换 → 重启 → 版本复核
|
||||
// → 失败自动回滚(DoUpgrade 内部完成)
|
||||
// apply=true → 解包 → 校验 → 备份 → 原子替换(**不重启**)
|
||||
// → 请求 guard 重拉 → 新内核启动时由 update 模块确认或回滚
|
||||
//
|
||||
// ★ 重启为什么不在 internal/update 里做:worker 是被守护方,只有 guard 知道
|
||||
// 这次退出该不该重拉(exit 42 → 重拉且不计失败轮次)。详见
|
||||
// cmd/homed/kernel_control.go。
|
||||
|
||||
package pluginmgr
|
||||
|
||||
@ -39,33 +43,37 @@ 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,
|
||||
"apply": false,
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"has_update": info.HasUpdate,
|
||||
"asset": info.Asset,
|
||||
"release": info.LatestURL,
|
||||
"reason": info.Reason,
|
||||
"hint": "确认后传 apply=true 执行升级(会替换二进制并重启内核,连接断开数秒)",
|
||||
"asset": info.Asset,
|
||||
"release": info.LatestURL,
|
||||
"reason": info.Reason,
|
||||
"hint": "确认后传 apply=true 执行升级(会替换二进制并重启内核,连接断开数秒)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// kernelUpdateApply 是 kernel_update 的执行半边(apply=true)。
|
||||
//
|
||||
// 只接受**本机已有的包**(pkg 参数):从 URL 现场下载需要处理
|
||||
// 校验/断点/重试,那是 CLI 的职责(homed-upgrade),agent 不应在
|
||||
// 推理过程中发起长下载(会占满工具调用超时)。
|
||||
func (p *Plugin) kernelUpdateApply(pkgPath string) (interface{}, error) {
|
||||
if pkgPath == "" {
|
||||
return map[string]interface{}{
|
||||
"error": "apply=true 需要 pkg(本机已存在的安装包路径;下载请跑 homed-upgrade -pkg <url>)",
|
||||
}, nil
|
||||
}
|
||||
st, err := os.Stat(pkgPath)
|
||||
if err != nil || st.IsDir() {
|
||||
return map[string]interface{}{
|
||||
"error": fmt.Sprintf("pkg 无效:%s", pkgPath),
|
||||
}, nil
|
||||
// 来源二选一(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-*")
|
||||
@ -74,39 +82,62 @@ func (p *Plugin) kernelUpdateApply(pkgPath string) (interface{}, error) {
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
root, err := update.ExtractPkg(pkgPath, tmp)
|
||||
root, rel, err := src.Resolve(tmp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解包: %w", err)
|
||||
return map[string]interface{}{"error": "取包失败: " + err.Error()}, nil
|
||||
}
|
||||
newBin := update.FindFile(root, "homed")
|
||||
newDG := update.FindFile(root, "depgraph.bin")
|
||||
if newBin == "" {
|
||||
return nil, fmt.Errorf("包内找不到 homed")
|
||||
return map[string]interface{}{"error": "包内找不到 homed"}, nil
|
||||
}
|
||||
|
||||
// ★ 升级会让本进程消失:先把"已受理"回给模型,再执行替换。
|
||||
//
|
||||
// 否则模型看到的是工具调用超时/断线,无法区分"升级成功"和"工具坏了"。
|
||||
// 返回后由本 goroutine 继续跑 DoUpgrade(替换+重启+回滚)。
|
||||
if kc := p.sdk.KernelControl(); kc != nil {
|
||||
_ = kc.RequestKernelRestart("kernel_update apply")
|
||||
// 走事件总线:升级的每一步都让 WebUI 看得到。
|
||||
var bus *events.Bus
|
||||
if p.sdk != nil {
|
||||
bus = p.sdk.EventBus()
|
||||
}
|
||||
go func() {
|
||||
// 走事件总线:升级的每一步(含重启前的最后几步)都让 WebUI 看得到。
|
||||
// 顺序上 publish 紧贴动作之前 —— 进程消失后事件就发不出去了。
|
||||
bus := (*events.Bus)(nil)
|
||||
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
|
||||
}
|
||||
if err := update.DoUpgradeBus(root, kernelBinPath(p), newDG, p.pluginDir, kernelDataDir(p), bus); err != nil {
|
||||
// DoUpgrade 内部失败已自行回滚;这里只留一条诊断日志
|
||||
fmt.Fprintf(os.Stderr, "[pluginmgr] kernel_update upgrade failed (rolled back): %v\n", err)
|
||||
}
|
||||
}()
|
||||
return map[string]interface{}{
|
||||
"accepted": true,
|
||||
"message": "升级已受理:正在替换二进制并重启。完成后用 kernel_update(apply=false)确认新版本。",
|
||||
}, 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 推导数据目录(升级确认标记的落点)。
|
||||
@ -114,8 +145,14 @@ func (p *Plugin) kernelUpdateApply(pkgPath string) (interface{}, error) {
|
||||
// 从 pluginDir 反推:<dataDir>/plugins ⇒ 上一级即 dataDir。
|
||||
// 与内核 --data 的值一致,update 模块(第 0 层)才能读到同一个标记。
|
||||
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 != "" {
|
||||
return filepath.Dir(filepath.Dir(p.pluginDir))
|
||||
return filepath.Dir(p.pluginDir)
|
||||
}
|
||||
return "/home/newqqagent"
|
||||
}
|
||||
@ -142,3 +179,47 @@ func kernelBinPath(p *Plugin) string {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@ -70,27 +70,31 @@ func TestKernelUpdate_CheckShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 4:kernel_update apply 缺 pkg ⇒ 明确报错并指向 CLI。
|
||||
func TestKernelUpdate_ApplyRequiresPkg(t *testing.T) {
|
||||
// 判据 4:kernel_update apply 没给来源 ⇒ 明确报错(不许"猜一个默认动作")。
|
||||
//
|
||||
// 旧判据要求错误里出现 homed-upgrade —— 那是"agent 不该在推理中发起长下载"
|
||||
// 时期的限制。现在支持 release=true 自动取包(下载放在内核侧统一实现),
|
||||
// 所以判据改为:必须说清"两种来源二选一"。
|
||||
func TestKernelUpdate_ApplyRequiresSource(t *testing.T) {
|
||||
p := &Plugin{pluginDir: t.TempDir()}
|
||||
out, err := p.kernelUpdateApply("")
|
||||
out, err := p.kernelUpdateApply("", false)
|
||||
if err != nil {
|
||||
t.Fatalf("应返回可呈现的错误: %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if !strings.Contains(fmtSprint(m["error"]), "pkg") {
|
||||
t.Errorf("错误应说明需要 pkg,实际: %v", m["error"])
|
||||
msg := fmtSprint(m["error"])
|
||||
if !strings.Contains(msg, "pkg") {
|
||||
t.Errorf("错误应说明需要 pkg,实际: %v", msg)
|
||||
}
|
||||
// 并应提示下载走 CLI(agent 不该在推理中发起长下载)
|
||||
if !strings.Contains(fmtSprint(m["error"]), "homed-upgrade") {
|
||||
t.Errorf("错误应指向 homed-upgrade,实际: %v", m["error"])
|
||||
if !strings.Contains(msg, "Release") && !strings.Contains(msg, "release") {
|
||||
t.Errorf("错误应说明可从 Release 自动更新,实际: %v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 5:kernel_update apply 的 pkg 是目录/不存在 ⇒ 拒绝。
|
||||
func TestKernelUpdate_ApplyRejectsBadPkg(t *testing.T) {
|
||||
p := &Plugin{pluginDir: t.TempDir()}
|
||||
out, _ := p.kernelUpdateApply(t.TempDir()) // 目录不是包
|
||||
out, _ := p.kernelUpdateApply(t.TempDir(), false) // 目录不是包
|
||||
m := out.(map[string]interface{})
|
||||
if _, hasErr := m["error"]; !hasErr {
|
||||
t.Errorf("目录不应被当作安装包,实际: %v", m)
|
||||
|
||||
@ -21,9 +21,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/plugin"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/update"
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/update"
|
||||
)
|
||||
|
||||
// platformBinary 按当前 OS/ARCH 选择正确的插件二进制文件名。
|
||||
@ -97,6 +98,9 @@ type Plugin struct {
|
||||
httpAddr string // 本实例的监听地址(默认 defaultHTTPAddr;来自 settings)
|
||||
sdk *sdk.PluginSDK
|
||||
pluginDir string
|
||||
|
||||
// unsubUpdateReq 注销 update_request 订阅(Stop 时调用)。
|
||||
unsubUpdateReq func()
|
||||
// maxPackageBytes 是单个插件包 **压缩后** 的上限(0 = 不限)。
|
||||
//
|
||||
// 为什么是配置项而不是常量:内嵌运行时(node+pi 之类)就要 ~100MB,
|
||||
@ -495,12 +499,26 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
|
||||
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{
|
||||
Name: "kernel_update",
|
||||
Description: "检查/升级**内核**(homed 二进制)。两阶段:默认(apply=false)只查 GitHub Release " +
|
||||
"与本地版本比较,不改动任何东西;看过结果后传 apply=true + pkg(本机已存在的安装包路径)" +
|
||||
"真正升级:校验 → 备份 → 原子替换 → 重启 → 版本复核 → 失败自动回滚。" +
|
||||
"升级会让内核重启,连接断开数秒。从 URL 下载新包请用 CLI 的 homed-upgrade。",
|
||||
"与本地版本比较,不改动任何东西;看过结果后传 apply=true,并给出包来源二选一:" +
|
||||
"pkg=本机安装包路径,或 release=true 从最新 GitHub Release 自动下载。" +
|
||||
"升级流程:校验 → 备份 → 原子替换 → 由 guard 重拉 → 新内核启动时确认,失败自动回滚。" +
|
||||
"升级会让内核重启,连接断开数秒。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -510,7 +528,11 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
|
||||
},
|
||||
"pkg": map[string]interface{}{
|
||||
"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) {
|
||||
apply, _ := args["apply"].(bool)
|
||||
pkgPath, _ := args["pkg"].(string)
|
||||
release, _ := args["release"].(bool)
|
||||
if apply {
|
||||
return p.kernelUpdateApply(pkgPath)
|
||||
return p.kernelUpdateApply(pkgPath, release)
|
||||
}
|
||||
return p.kernelUpdateCheck()
|
||||
})
|
||||
@ -732,7 +755,6 @@ func (p *Plugin) pluginUpdate(apply bool, scope string) (interface{}, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
// ======== HTTP API ========
|
||||
|
||||
func (p *Plugin) startHTTPServer() {
|
||||
|
||||
@ -156,6 +156,13 @@ func TestEveryPublishedEventHasAnOutwardPath(t *testing.T) {
|
||||
// 插件间握手事件:clawhubadapter → skillmgr 移交 SKILL 条目,
|
||||
// 属内核内部的职责链,前端无需感知。
|
||||
"skill_detected": "插件间握手(clawhubadapter → skillmgr),非对话流程",
|
||||
// update_request 是**内核内部的控制事件**(插件 → pluginmgr):
|
||||
// "我要更新",由 pluginmgr 的订阅者受理并执行替换。
|
||||
//
|
||||
// 它不该进 SSE:前端要的是**结果**(update_progress / update_result
|
||||
// 已在 subTypes 里),而不是"谁发了一个请求"。把它推给浏览器只会
|
||||
// 让界面多出一条没有下文的消息。
|
||||
"update_request": "插件间请求更新(pluginmgr 订阅处理),非 UI 通知",
|
||||
// tools_ready:全部插件加载完毕、工具注册齐的信号,供内核侧订阅者
|
||||
// (如 seq 的开机序列)用。它是**内核内部**的就绪信号,
|
||||
// 不是对话流程事件,前端无需感知。
|
||||
|
||||
@ -2,23 +2,36 @@ package webui
|
||||
|
||||
// 内核更新的 HTTP 端点(GET 检查 / POST 升级)。
|
||||
//
|
||||
// ## 与 agent 工具同底层的架构约束
|
||||
// ## 三端共享同一底层(架构约束)
|
||||
//
|
||||
// agent 的 kernel_update 工具、本端点、CLI(homed-upgrade)三者共享
|
||||
// internal/update 的 CheckRelease / DoUpgrade —— 三份实现必然漂移,
|
||||
// 会出现"WebUI 说有新版、agent 说没有"的分裂状态。
|
||||
// internal/update 的 CheckRelease / Apply —— 三份实现必然漂移,会出现
|
||||
// "WebUI 说有新版、agent 说没有"的分裂状态。
|
||||
// 本文件只做 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 (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/update"
|
||||
@ -38,45 +51,85 @@ func (h *Handler) handleKernelUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleKernelUpdateCheck 处理 GET —— 只检查不改动。
|
||||
func (h *Handler) handleKernelUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSONError(w, http.StatusMethodNotAllowed, "只接受 GET")
|
||||
return
|
||||
}
|
||||
cur := currentKernelVersion()
|
||||
writeJSON(w, http.StatusOK, update.CheckRelease(cur))
|
||||
writeJSON(w, http.StatusOK, update.CheckRelease(currentKernelVersion()))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeJSONError(w, http.StatusMethodNotAllowed, "只接受 POST")
|
||||
var body struct {
|
||||
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
|
||||
}
|
||||
// ★ 升级会让进程重启:先把响应写回去(同 kernel/restart 的教训)
|
||||
writeJSON(w, http.StatusAccepted, map[string]interface{}{
|
||||
"accepted": true,
|
||||
"message": "升级已受理。内核将替换二进制并重启,连接会断开数秒。完成后刷新状态页确认新版本。",
|
||||
})
|
||||
|
||||
var body struct {
|
||||
Pkg string `json:"pkg"`
|
||||
binPath := update.KernelBinaryPath()
|
||||
|
||||
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 的上游):升级每一步都让界面看得到
|
||||
var bus *events.Bus
|
||||
if h.sdk != nil {
|
||||
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 的版本(尽力而为)。
|
||||
func currentKernelVersion() *update.BinVersion {
|
||||
bin := "/usr/local/bin/homed"
|
||||
bin := update.KernelBinaryPath()
|
||||
if _, err := os.Stat(bin); err != nil {
|
||||
return nil
|
||||
}
|
||||
@ -87,33 +140,13 @@ func currentKernelVersion() *update.BinVersion {
|
||||
return v
|
||||
}
|
||||
|
||||
// runKernelUpgrade 在后台执行完整升级闭环(含重启与回滚)。
|
||||
// dataDirOf 推导数据目录(升级确认标记的落点)。
|
||||
//
|
||||
// bus 为 nil 时退化成纯日志(该部署未接事件总线)。
|
||||
func runKernelUpgrade(pkgPath string, bus *events.Bus) {
|
||||
if pkgPath == "" {
|
||||
// 没给本地包:从最新 release 资产下载。
|
||||
// 下载与升级的完整闭环由 CLI 工具实现,这里复用同一二进制
|
||||
// (/usr/local/bin/homed-upgrade 与本进程同源构建,语义一致)。
|
||||
if _, err := exec.LookPath("homed-upgrade"); err != nil {
|
||||
return
|
||||
}
|
||||
return // 下载逻辑见 CLI;此处仅受理本地包路径
|
||||
// 与内核 `--data` 必须一致,否则第 0 层 update 模块读不到 pending 标记。
|
||||
// 优先用环境变量显式指定(部署方知道自己在哪),否则退回本机生产路径。
|
||||
func dataDirOf(binPath string) string {
|
||||
if d := os.Getenv("HOMEAGENT_DATA_DIR"); d != "" {
|
||||
return d
|
||||
}
|
||||
// 本地包:直接走共享底层(与 CLI 同代码路径)
|
||||
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)
|
||||
return "/home/newqqagent"
|
||||
}
|
||||
|
||||
@ -246,31 +246,36 @@ func FileSize(p string) int64 {
|
||||
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 场景没有总线)。
|
||||
func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events.Bus) error {
|
||||
// 本包若自己调 `systemctl restart`,会带来三个问题:
|
||||
//
|
||||
// · 杀掉整个 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")
|
||||
if newBin == "" {
|
||||
return fmt.Errorf("包内无 homed")
|
||||
return nil, fmt.Errorf("包内无 homed")
|
||||
}
|
||||
|
||||
// ── 预检:新二进制必须能自报版本(说明它是可执行的、架构对的)──
|
||||
nv, err := ReadBinVersion(newBin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("预检失败,新二进制不可执行: %w", err)
|
||||
return nil, fmt.Errorf("预检失败,新二进制不可执行: %w", err)
|
||||
}
|
||||
publishProgress(bus, "kernel", "", "verify", "新内核可执行,版本 "+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()
|
||||
backup := filepath.Join("/root", "homed-upgrade-backup-"+ts)
|
||||
if err := os.MkdirAll(backup, 0755); err != nil {
|
||||
return fmt.Errorf("建备份目录: %w", err)
|
||||
return nil, fmt.Errorf("建备份目录: %w", err)
|
||||
}
|
||||
targets := []string{binPath}
|
||||
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 {
|
||||
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)))
|
||||
@ -297,9 +302,6 @@ func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events.
|
||||
//
|
||||
// 顺序很关键:先写标记再替换。若反过来(替换后才写),"替换成功但
|
||||
// 没来得及写标记就崩了"会丢标记 ⇒ 下次启动不知道该回滚。
|
||||
//
|
||||
// 标记由 update 模块(第 0 层)在下次启动时读;读到就说明上次升级
|
||||
// 留下的版本没能活到清除标记 ⇒ 回滚(见 internal/update/rollback.go)。
|
||||
curVer := &BinVersion{}
|
||||
if v, verr := ReadBinVersion(binPath); verr == nil {
|
||||
curVer = v
|
||||
@ -314,14 +316,15 @@ func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events.
|
||||
}); perr != nil {
|
||||
// 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力,
|
||||
// 而不带回滚的升级等于赌运气。
|
||||
return fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr)
|
||||
return nil, fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr)
|
||||
}
|
||||
fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir))
|
||||
}
|
||||
|
||||
rollback := func(cause error) error {
|
||||
// restore 在替换失败时把已动的文件还原(尚未重启,机器仍可启动)。
|
||||
restore := func(cause error) (*Outcome, error) {
|
||||
publishProgress(bus, "kernel", "", "rollback", cause.Error())
|
||||
fmt.Printf("\n❌ %v\n== 开始回滚 ==\n", cause)
|
||||
fmt.Printf("\n❌ %v\n== 就地还原(尚未重启,机器仍可启动)==\n", cause)
|
||||
if dataDir != "" {
|
||||
if cerr := ClearPendingUpgrade(dataDir); cerr != nil {
|
||||
fmt.Printf(" ⚠️ 清除升级确认标记失败: %v\n", cerr)
|
||||
@ -330,74 +333,41 @@ func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events.
|
||||
for _, t := range targets {
|
||||
src := filepath.Join(backup, filepath.Base(t))
|
||||
if err := CopyFile(src, t); err != nil {
|
||||
fmt.Printf(" ⚠️ 回滚 %s 失败: %v(需人工处理)\n", t, err)
|
||||
fmt.Printf(" ⚠️ 还原 %s 失败: %v(需人工处理)\n", t, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" 已恢复 %s\n", t)
|
||||
}
|
||||
if err := restartService(); err != nil {
|
||||
fmt.Printf(" ⚠️ 重启失败: %v(需人工处理)\n", err)
|
||||
} else if !WaitHealthy(60) {
|
||||
fmt.Println(" ⚠️ 回滚后仍不健康,需人工介入")
|
||||
} else {
|
||||
fmt.Println(" ✅ 已回滚并恢复正常运行")
|
||||
}
|
||||
return fmt.Errorf("升级失败已回滚: %w", cause)
|
||||
publishResult(bus, "kernel", "", false, curVer.Version, nv.Version, true, cause.Error())
|
||||
return nil, fmt.Errorf("替换失败已还原(未重启): %w", cause)
|
||||
}
|
||||
|
||||
// ── 替换(depgraph 必须与 homed 同批,顺序无关但都要成)──
|
||||
if newDG != "" {
|
||||
dst := filepath.Join(filepath.Dir(binPath), "depgraph.bin")
|
||||
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")
|
||||
}
|
||||
if err := CopyFile(newBin, binPath); err != nil {
|
||||
return rollback(fmt.Errorf("替换 homed: %w", err))
|
||||
return restore(fmt.Errorf("替换 homed: %w", err))
|
||||
}
|
||||
fmt.Println("已替换 homed")
|
||||
publishProgress(bus, "kernel", "", "ready", "新内核已就位,等待重启")
|
||||
|
||||
// ── 重启 + 后置验证 ──
|
||||
if err := restartService(); err != nil {
|
||||
return rollback(fmt.Errorf("重启: %w", err))
|
||||
}
|
||||
if !WaitHealthy(90) {
|
||||
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
|
||||
return &Outcome{
|
||||
From: curVer,
|
||||
To: nv,
|
||||
BackupDir: backup,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// waitHealthy 轮询 systemd 的 active 状态。
|
||||
func WaitHealthy(seconds int) bool {
|
||||
for i := 0; i < seconds; i++ {
|
||||
out, err := exec.Command("systemctl", "is-active", "homeagent").Output()
|
||||
if err == nil && strings.TrimSpace(string(out)) == "active" {
|
||||
// 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
|
||||
// Outcome 是一次替换的结果(**不含重启**,重启由调用方走内核控制通道)。
|
||||
type Outcome struct {
|
||||
From *BinVersion // 升级前版本(回滚目标)
|
||||
To *BinVersion // 已就位的新版本
|
||||
BackupDir string // 备份目录(确认稳定后可删)
|
||||
}
|
||||
|
||||
func CopyFile(src, dst string) error {
|
||||
@ -437,9 +407,6 @@ func NowStamp() string {
|
||||
return time.Now().Format("20060102-150405")
|
||||
}
|
||||
|
||||
// sleepSecond 隔一秒(抽出来便于将来换成可注入的等待,测试不必真睡)。
|
||||
func sleepSecond() { time.Sleep(time.Second) }
|
||||
|
||||
// inferVersionFromRunning 在老内核不支持 --version 时,尽力推断当前版本。
|
||||
//
|
||||
// 手段按可靠性排序,任一成功即返回:
|
||||
|
||||
101
internal/update/source.go
Normal file
101
internal/update/source.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user