Files
HomeAgent/internal/update/source.go
JianFeeeee 817f6c59ae 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 字段。
2026-10-26 13:45:54 +08:00

102 lines
3.3 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.

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)
}
}