Files
HomeAgent/internal/plugins/webui/kernel_update.go
JianFeeeee 85fd38b7d1 fix(update): 统一 dataDir 推导,三端共用 update.ResolveDataDir
范围说明:本 commit 与 H1 属同一缺陷的另一面。Apply 现在会拒绝空
dataDir,而三端各自的推导逻辑此前会**真的返回空**(或写死生产路径),
不一起改则 H1 会把所有升级都拒掉、或让 pending 落到内核读不到的地方。

三处原来的推导:
  · cmd/homed-upgrade/main.go     env → 写死 /home/newqqagent
  · pluginmgr/kernel_update.go    env → Dir(pluginDir)
  · webui/kernel_update.go        env → 空(由调用方报错)

问题:① 硬编码 /home/newqqagent 在目录/用户迁移后指向不存在的路径;
     ② 从 pluginDir 反推与 webui 侧不一致,同一台机器两条升级通道算出
        两个 dataDir ⇒ 标记落在 A、第 0 层 update 从 B 读 ⇒ 升级启动
        失败时静默不回滚(症状是崩溃循环,极难反查到标记)。

现在三端统一调用 update.ResolveDataDir(exePath):
  ① HOMEAGENT_DATA_DIR(部署方显式声明,唯一可靠来源)
  ② <exe 所在目录>/data 兜底(替代原先的硬编码生产路径)
并删掉 cmd/homed-upgrade 里写死的 /home/newqqagent。

本 commit 可单独回退:回退后仅剩「各端自己推导」,与 H1 组合会退化为
「空 dataDir 一律拒绝升级」(安全侧失败,不会产生无回滚能力的升级)。
2026-10-28 17:20:40 +08:00

152 lines
5.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 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 推导数据目录(升级确认标记 / 备份的落点)。
//
// ★ 三端共用 update.ResolveDataDir:pending 标记落在哪,第 0 层 update
// 模块就从哪读。两边一漂移,升级启动失败时就**静默不回滚**。
// 返回空的情况已不存在(ResolveDataDir 有 exe 同级 data 兜底),
// 但仍不写死任何生产绝对路径:迁移后它指向不存在的目录(静默不回滚)。
func dataDirOf(binPath string) string {
return update.ResolveDataDir(binPath)
}