Files
HomeAgent/internal/plugins/webui/kernel_update.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

153 lines
5.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.

package webui
// 内核更新的 HTTP 端点(GET 检查 / POST 升级)。
//
// ## 三端共享同一底层(架构约束)
//
// agent 的 kernel_update 工具、本端点、CLI(homed-upgrade)三者共享
// internal/update 的 CheckRelease / Apply —— 三份实现必然漂移,会出现
// "WebUI 说有新版、agent 说没有"的分裂状态。
// 本文件只做 HTTP 形状转换,不含业务逻辑。
//
// ## 包来源二选一
//
// {"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"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/update"
)
// handleKernelUpdate 是 /api/v1/kernel/update 的统一入口(GET 检查 / POST 升级)。
func (h *Handler) handleKernelUpdate(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleKernelUpdateCheck(w, r)
case http.MethodPost:
h.handleKernelUpdateApply(w, r)
default:
writeJSONError(w, http.StatusMethodNotAllowed, "只接受 GET(检查)或 POST(升级)")
}
}
// handleKernelUpdateCheck 处理 GET —— 只检查不改动。
func (h *Handler) handleKernelUpdateCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, update.CheckRelease(currentKernelVersion()))
}
// handleKernelUpdateApply 处理 POST /api/v1/kernel/update —— 执行升级。
func (h *Handler) handleKernelUpdateApply(w http.ResponseWriter, r *http.Request) {
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
}
binPath := update.KernelBinaryPath()
tmp, err := os.MkdirTemp("", "kernel-upgrade-*")
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "建临时目录失败: "+err.Error())
return
}
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()
}
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 := update.KernelBinaryPath()
if _, err := os.Stat(bin); err != nil {
return nil
}
v, err := update.ReadBinVersion(bin)
if err != nil {
return nil
}
return v
}
// dataDirOf 推导数据目录(升级确认标记的落点)。
//
// 与内核 `--data` 必须一致,否则第 0 层 update 模块读不到 pending 标记。
// 优先用环境变量显式指定(部署方知道自己在哪),否则退回本机生产路径。
func dataDirOf(binPath string) string {
if d := os.Getenv("HOMEAGENT_DATA_DIR"); d != "" {
return d
}
return "/home/newqqagent"
}