mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
refactor(update): 内核与插件更新合并为 update 子系统(三端共享一个底层)
## 为什么合并(用户口径:更新部分应独立为子系统)
之前 kernelupgrade / pluginupdate 两个包,但它们共享三样东西,
分开写就要各复制一份,然后慢慢不一致:
① **版本比较** —— 合并前共 6 个函数做同一件事:
kernel 侧 ParseSemver / CmpSemver / versionNewer / trimTagV
plugin 侧 trimV / isNewer / semverNums(还自带一份正则)
且**行为不一致**:kernel 侧只挡 len<2,"1.4" 漏过去被填成 1.4.0;
plugin 侧的正则要求两段所以也漏。两份都把 "1.4" 当合法版本。
② **两阶段交互**(检查 → 确认 → 执行)—— 同一套用户口径。
③ **失败要响亮** —— 都不静默降级。
## 收敛结果
internal/update/
doc.go 子系统头:职责边界、三端共享契约、为何不分家
semver.go ParseSemver / CmpSemver / Newer / TrimTag(**唯一一份**)
kernel.go 内核升级闭环(解包→校验→备份→替换→重启→复核→回滚)
release.go GitHub Release 查询
plugin.go 插件升级(查仓库→拉源码→hmapdev 编译→产物)
三端共享:
agent 工具(plugin_update / kernel_update) ┐
WebAPI(/plugins/update、/kernel/update) ├─ internal/update
CLI(homed-upgrade) ┘
入口层只做参数解析与结果呈现,不含业务逻辑 —— 否则会出现
"agent 说没有新版、WebUI 却升了级"的分裂状态。
## 合并时抓到的两个真实 bug
① **ParseSemver 只挡 len<2** ⇒ "1.4" 被零值填成 1.4.0,
「1.4 → 1.4.0」被判完全相同(说"无需升级"),而两者内容未必相同。
现在要求恰好三段。
② **不认 v 前缀** ⇒ GitHub release tag 全是 v1.4.0 形态,
CLI 对"当前 v1.4.0 vs 包内 v1.4.0"判不可比、只能退回字符串比较。
现在 TrimTag 去 v 前缀后再比。
## 新增入口
· plugin_update(agent 工具)+ GET/POST /plugins/update
—— 两阶段:apply=false 只检查(列出 updatable / no_repo 两类,
后者是"未声明仓库、无法自动更新"的内置/私有插件);
apply=true 需显式 plugins=all 或名单(不默认全升)
· kernel_update(agent 工具)+ GET/POST /kernel/update
—— 两阶段同构;apply 只收本机包路径(URL 下载是 CLI 职责,
agent 不该在推理中发起长下载)
判据:
· internal/update: Newer / TrimTag / ParseSemver 的边界(含上述两个 bug 的回归)
· pluginmgr 入口层 5 条:检查形状、apply 必须显式给范围、
kernel apply 必须给 pkg 且指向 homed-upgrade、拒绝目录当包
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
// Command homed-upgrade 是**内核**升级闭环的 CLI 入口(默认只探测,不写盘)。
|
||||
//
|
||||
// 实现全部在 internal/kernelupgrade —— 内核侧的 kernel_update 工具走同一套
|
||||
// 实现全部在 internal/update —— 内核侧的 kernel_update 工具走同一套
|
||||
// 代码,避免"CLI 能升级、agent 调用却把机器搞坏"这类两份实现的漂移。
|
||||
//
|
||||
// ## 范围:只面向内核
|
||||
@ -34,7 +34,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/kernelupgrade"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/update"
|
||||
)
|
||||
|
||||
// fail 打印错误并以非零退出(CLI 专属;库内部一律返回 error)。
|
||||
@ -62,45 +62,45 @@ func main() {
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
fmt.Printf("== 解包 %s ==\n", *pkgPath)
|
||||
root, err := kernelupgrade.ExtractPkg(*pkgPath, tmp)
|
||||
root, err := update.ExtractPkg(*pkgPath, tmp)
|
||||
if err != nil {
|
||||
fail("解包: %v", err)
|
||||
}
|
||||
|
||||
// ── 1. 当前版本 ──
|
||||
cur, curErr := kernelupgrade.ReadBinVersion(*binPath)
|
||||
cur, curErr := update.ReadBinVersion(*binPath)
|
||||
if curErr != nil {
|
||||
fmt.Printf("⚠️ 读当前内核版本失败: %v\n", curErr)
|
||||
cur = nil
|
||||
} else {
|
||||
fmt.Printf("当前内核: %s (commit %s)\n", cur.Version, kernelupgrade.ShortCommit(cur.Commit))
|
||||
fmt.Printf("当前内核: %s (commit %s)\n", cur.Version, update.ShortCommit(cur.Commit))
|
||||
}
|
||||
|
||||
// ── 2. 新版本 ──
|
||||
newBin := kernelupgrade.FindFile(root, "homed")
|
||||
newBin := update.FindFile(root, "homed")
|
||||
if newBin == "" {
|
||||
fail("包内找不到 homed")
|
||||
}
|
||||
nv, err := kernelupgrade.ReadBinVersion(newBin)
|
||||
nv, err := update.ReadBinVersion(newBin)
|
||||
if err != nil {
|
||||
fail("新内核无法自报版本(可能不是本仓构建的二进制): %v", err)
|
||||
}
|
||||
fmt.Printf("包内内核: %s (commit %s)\n", nv.Version, kernelupgrade.ShortCommit(nv.Commit))
|
||||
fmt.Printf("包内内核: %s (commit %s)\n", nv.Version, update.ShortCommit(nv.Commit))
|
||||
|
||||
// ── 3. 版本比较 ──
|
||||
verdict := kernelupgrade.CompareVersions(cur, nv)
|
||||
verdict := update.CompareVersions(cur, nv)
|
||||
fmt.Printf("\n== 结论 ==\n%s\n", verdict)
|
||||
|
||||
// ── 4. depgraph 必须与新二进制同批 ──
|
||||
newDG := kernelupgrade.FindFile(root, "depgraph.bin")
|
||||
newDG := update.FindFile(root, "depgraph.bin")
|
||||
if newDG == "" {
|
||||
fmt.Println("⚠️ 包内缺 depgraph.bin —— 内核启动会因依赖图不匹配拒绝启动")
|
||||
} else {
|
||||
fmt.Printf("depgraph.bin: 包内已带(%d 字节),将与 homed 同批替换\n", kernelupgrade.FileSize(newDG))
|
||||
fmt.Printf("depgraph.bin: 包内已带(%d 字节),将与 homed 同批替换\n", update.FileSize(newDG))
|
||||
}
|
||||
|
||||
// ── 5. 插件兼容性预判(OTA 最容易出事的点)──
|
||||
incompat := kernelupgrade.ProbePluginCompat(*pluginsDir, nv.SDKVersion)
|
||||
incompat := update.ProbePluginCompat(*pluginsDir, nv.SDKVersion)
|
||||
if len(incompat) > 0 {
|
||||
fmt.Printf("\n== 插件兼容性警告(%d 个)==\n", len(incompat))
|
||||
fmt.Println("换内核后这些插件会握手失败并被**自动禁用**(写入禁用配置 + 原因):")
|
||||
@ -118,7 +118,7 @@ func main() {
|
||||
}
|
||||
|
||||
// ── 6. 真正升级(备份 → 替换 → 重启 → 后置验证 → 失败回滚)──
|
||||
if err := kernelupgrade.DoUpgrade(root, *binPath, newDG, *pluginsDir); err != nil {
|
||||
if err := update.DoUpgrade(root, *binPath, newDG, *pluginsDir); err != nil {
|
||||
fail("升级失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/kernelupgrade"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/update"
|
||||
)
|
||||
|
||||
// ── 版本比较的判据 ────────────────────────────────────────────
|
||||
@ -15,46 +15,46 @@ import (
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cur *kernelupgrade.BinVersion
|
||||
nv *kernelupgrade.BinVersion
|
||||
cur *update.BinVersion
|
||||
nv *update.BinVersion
|
||||
wantSub string // 结论文案必须包含的片段
|
||||
}{
|
||||
{
|
||||
name: "正常升级",
|
||||
cur: &kernelupgrade.BinVersion{Version: "1.3.0", Commit: "aaa"},
|
||||
nv: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "bbb"},
|
||||
cur: &update.BinVersion{Version: "1.3.0", Commit: "aaa"},
|
||||
nv: &update.BinVersion{Version: "1.4.0", Commit: "bbb"},
|
||||
wantSub: "可升级",
|
||||
},
|
||||
{
|
||||
name: "降级必须显式警告",
|
||||
cur: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"},
|
||||
nv: &kernelupgrade.BinVersion{Version: "1.3.0", Commit: "bbb"},
|
||||
cur: &update.BinVersion{Version: "1.4.0", Commit: "aaa"},
|
||||
nv: &update.BinVersion{Version: "1.3.0", Commit: "bbb"},
|
||||
wantSub: "降级",
|
||||
},
|
||||
{
|
||||
name: "同版本同 commit = 无需升级",
|
||||
cur: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"},
|
||||
nv: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"},
|
||||
cur: &update.BinVersion{Version: "1.4.0", Commit: "aaa"},
|
||||
nv: &update.BinVersion{Version: "1.4.0", Commit: "aaa"},
|
||||
wantSub: "完全相同",
|
||||
},
|
||||
{
|
||||
// ★ 这条最容易被忽略:版本号没变但内容变了(同版本重发)。
|
||||
// 不报出来会让用户以为"升级了其实没升"或反之。
|
||||
name: "同版本不同 commit 必须点明",
|
||||
cur: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"},
|
||||
nv: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "bbb"},
|
||||
cur: &update.BinVersion{Version: "1.4.0", Commit: "aaa"},
|
||||
nv: &update.BinVersion{Version: "1.4.0", Commit: "bbb"},
|
||||
wantSub: "同版本重发",
|
||||
},
|
||||
{
|
||||
// 非标准形态(1.4.0dev):走字符串回退,不能说"降级"
|
||||
name: "非标准版本号走字符串比较",
|
||||
cur: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"},
|
||||
nv: &kernelupgrade.BinVersion{Version: "1.4.0dev", Commit: "bbb"},
|
||||
cur: &update.BinVersion{Version: "1.4.0", Commit: "aaa"},
|
||||
nv: &update.BinVersion{Version: "1.4.0dev", Commit: "bbb"},
|
||||
wantSub: "commit 不同",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := kernelupgrade.CompareVersions(c.cur, c.nv)
|
||||
got := update.CompareVersions(c.cur, c.nv)
|
||||
if !strings.Contains(got, c.wantSub) {
|
||||
t.Errorf("%s: 结论应含 %q,实际 %q", c.name, c.wantSub, got)
|
||||
}
|
||||
@ -66,7 +66,7 @@ func TestCompareVersions(t *testing.T) {
|
||||
// 这是实测踩到的:线上内核是加 --version 之前的版本,第一次探测必然失败。
|
||||
// 若此时直接退出,所有用户第一次升级都要人工介入。
|
||||
func TestCompareVersions_UnknownCurrentDoesNotBlock(t *testing.T) {
|
||||
got := kernelupgrade.CompareVersions(nil, &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "bbb"})
|
||||
got := update.CompareVersions(nil, &update.BinVersion{Version: "1.4.0", Commit: "bbb"})
|
||||
if strings.Contains(got, "❌") {
|
||||
t.Errorf("当前版本未知不应判失败,实际: %s", got)
|
||||
}
|
||||
@ -81,16 +81,20 @@ func TestParseSemver(t *testing.T) {
|
||||
// 只测**可观察行为**(返回 nil 与否),不探私有字段 ——
|
||||
// 跨包断言内部字段会把重构锁死,而"解析对不对"最终要由
|
||||
// CompareVersions 的行为体现(上面那组用例已覆盖)。
|
||||
if kernelupgrade.ParseSemver("1.4.0") == nil {
|
||||
if update.ParseSemver("1.4.0") == nil {
|
||||
t.Error("1.4.0 应能解析")
|
||||
}
|
||||
// dev 后缀不算正式语义版本(否则 1.4.0dev 会被当成 1.4.0 比大小)
|
||||
if v := kernelupgrade.ParseSemver("1.4.0dev"); v != nil {
|
||||
if v := update.ParseSemver("1.4.0dev"); v != nil {
|
||||
t.Errorf("1.4.0dev 应回退字符串比较,实际解析成 %+v", v)
|
||||
}
|
||||
// ★ "v1.4.0" 现在**算**合法:GitHub 的 release tag 就是 v 前缀形态
|
||||
//(hologramagent 的 tag 全部形如 v1.4.0)。早期实现不认 v 前缀,
|
||||
// 于是 CLI 对"当前 v1.4.0 vs 包内 v1.4.0"判不可比、只能退回字符串比较。
|
||||
//
|
||||
// 不完整串同样不算
|
||||
for _, bad := range []string{"", "1", "1.4", "v1.4.0", "1.4.x"} {
|
||||
if v := kernelupgrade.ParseSemver(bad); v != nil {
|
||||
for _, bad := range []string{"", "1", "1.4", "v1.4", "1.4.x", "1.4.4.4"} {
|
||||
if v := update.ParseSemver(bad); v != nil {
|
||||
t.Errorf("%q 不应被解析成语义版本,实际 %+v", bad, v)
|
||||
}
|
||||
}
|
||||
@ -101,11 +105,11 @@ func TestParseSemver(t *testing.T) {
|
||||
func TestFirstSemverLike(t *testing.T) {
|
||||
// waiter 的真实输出形态
|
||||
line := "waiter 1.4.0 (commit 98850e1, built 2026-10-17T09:20:25Z)"
|
||||
if got := kernelupgrade.FirstSemverLike(line); got != "1.4.0" {
|
||||
if got := update.FirstSemverLike(line); got != "1.4.0" {
|
||||
t.Errorf("应从 waiter 输出取到 1.4.0,实际 %q", got)
|
||||
}
|
||||
// 不能把日期当版本号
|
||||
if got := kernelupgrade.FirstSemverLike("built 2026-10-17"); got != "" {
|
||||
if got := update.FirstSemverLike("built 2026-10-17"); got != "" {
|
||||
t.Errorf("日期不应被识别为版本号,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
126
internal/plugins/pluginmgr/kernel_update.go
Normal file
126
internal/plugins/pluginmgr/kernel_update.go
Normal file
@ -0,0 +1,126 @@
|
||||
// kernel_update 工具的 agent 侧实现。
|
||||
//
|
||||
// ## 三个入口共享一个底层(架构约束)
|
||||
//
|
||||
// agent 工具 kernel_update ┐
|
||||
// WebAPI /kernel/update ├─ internal/update(CheckRelease / DoUpgrade)
|
||||
// CLI homed-upgrade ┘
|
||||
//
|
||||
// 三份实现必然漂移("agent 说没有新版、WebUI 却升了"),所以入口层只做
|
||||
// 参数解析与结果呈现。
|
||||
//
|
||||
// ## 为什么实现放在 pluginmgr 而不是 agent/core
|
||||
//
|
||||
// 1. 与 plugin_update 对称(都在 pluginmgr,用户认知一致)
|
||||
// 2. agent/core 拿不到 SDK 句柄,而升级完成后要 RequestKernelRestart
|
||||
// (那是 sdk.KernelControlAPI)
|
||||
// 3. 与内核工具分派(toolcall.go)不同:这里走插件的 RegisterTool,
|
||||
// 不需要改核心包
|
||||
//
|
||||
// ## 两阶段(用户口径)
|
||||
//
|
||||
// apply=false(默认)→ 只查 GitHub Release 与本地版本比较,不改动任何东西
|
||||
// apply=true → 下载/解包 → 校验 → 备份 → 原子替换 → 重启 → 版本复核
|
||||
// → 失败自动回滚(DoUpgrade 内部完成)
|
||||
|
||||
package pluginmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/update"
|
||||
)
|
||||
|
||||
// kernelUpdateCheck 是 kernel_update 的检查半边(apply=false)。
|
||||
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,
|
||||
"has_update": info.HasUpdate,
|
||||
"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
|
||||
}
|
||||
|
||||
tmp, err := os.MkdirTemp("", "kernel-upgrade-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("建临时目录: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
root, err := update.ExtractPkg(pkgPath, tmp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解包: %w", err)
|
||||
}
|
||||
newBin := update.FindFile(root, "homed")
|
||||
newDG := update.FindFile(root, "depgraph.bin")
|
||||
if newBin == "" {
|
||||
return nil, fmt.Errorf("包内找不到 homed")
|
||||
}
|
||||
|
||||
// ★ 升级会让本进程消失:先把"已受理"回给模型,再执行替换。
|
||||
//
|
||||
// 否则模型看到的是工具调用超时/断线,无法区分"升级成功"和"工具坏了"。
|
||||
// 返回后由本 goroutine 继续跑 DoUpgrade(替换+重启+回滚)。
|
||||
if kc := p.sdk.KernelControl(); kc != nil {
|
||||
_ = kc.RequestKernelRestart("kernel_update apply")
|
||||
}
|
||||
go func() {
|
||||
if err := update.DoUpgrade(root, kernelBinPath(p), newDG, p.pluginDir); 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
|
||||
}
|
||||
|
||||
// currentKernelVersion 读本机 homed 自报版本(尽力而为)。
|
||||
func currentKernelVersion(p *Plugin) *update.BinVersion {
|
||||
v, err := update.ReadBinVersion(kernelBinPath(p))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// kernelBinPath 是本机 homed 的路径。
|
||||
//
|
||||
// 由 pluginDir 推导(<data>/../homed 不总是成立),优先按**本进程**
|
||||
// 的可执行路径:homed 与内核同二进制,这是最可靠的来源。
|
||||
func kernelBinPath(p *Plugin) string {
|
||||
if exe, err := os.Executable(); err == nil && filepath.Base(exe) == "homed" {
|
||||
return exe
|
||||
}
|
||||
if _, err := os.Stat("/usr/local/bin/homed"); err == nil {
|
||||
return "/usr/local/bin/homed"
|
||||
}
|
||||
return "homed"
|
||||
}
|
||||
105
internal/plugins/pluginmgr/kernel_update_test.go
Normal file
105
internal/plugins/pluginmgr/kernel_update_test.go
Normal file
@ -0,0 +1,105 @@
|
||||
package pluginmgr
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── plugin_update / kernel_update 的入口层判据 ────────────────
|
||||
//
|
||||
// 这里只测**入口层**(参数解析、范围选择、结果形状);
|
||||
// 底层逻辑(Check / Upgrade / DoUpgrade)的判据在各自包里:
|
||||
// internal/update — 拉源码、编译、版本比较
|
||||
// internal/update — 版本比较、解包、替换、回滚
|
||||
// 入口层若长出业务逻辑,就违背了"三端共享一个底层"的架构约束。
|
||||
|
||||
// 判据 1:plugin_update 检查阶段(apply=false)不要求 plugins 参数,
|
||||
// 且结果含 updatable / no_repo 两类 —— 两者必须分开。
|
||||
//
|
||||
// 混在一起的后果:用户无法区分"没更新"和"根本不能自动更新"(内置插件)。
|
||||
func TestPluginUpdate_CheckShape(t *testing.T) {
|
||||
p := &Plugin{pluginDir: t.TempDir()} // 空目录:没有已装插件
|
||||
out, err := p.pluginUpdate(false, "")
|
||||
if err != nil {
|
||||
t.Fatalf("检查不应失败: %v", err)
|
||||
}
|
||||
m, ok := out.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("返回应为 map,实际 %T", out)
|
||||
}
|
||||
if m["apply"] != false {
|
||||
t.Errorf("apply 应为 false,实际 %v", m["apply"])
|
||||
}
|
||||
if _, ok := m["updatable"]; !ok {
|
||||
t.Error("结果应有 updatable 字段")
|
||||
}
|
||||
if _, ok := m["no_repo"]; !ok {
|
||||
t.Error("结果应有 no_repo 字段(未声明仓库的插件单独列出)")
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 2:apply=true 但没给 plugins ⇒ 明确报错,不默认全升。
|
||||
//
|
||||
// 默认全升的危险:一次误触就把所有插件换了。
|
||||
func TestPluginUpdate_ApplyRequiresScope(t *testing.T) {
|
||||
p := &Plugin{pluginDir: t.TempDir()}
|
||||
out, err := p.pluginUpdate(true, "")
|
||||
if err != nil {
|
||||
t.Fatalf("应返回可呈现的错误而不是 error: %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if _, hasErr := m["error"]; !hasErr {
|
||||
t.Errorf("apply=true 缺 plugins 时必须报错,实际: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 3:kernel_update 检查阶段不依赖包存在,返回版本比较结果。
|
||||
func TestKernelUpdate_CheckShape(t *testing.T) {
|
||||
p := &Plugin{pluginDir: t.TempDir()}
|
||||
out, err := p.kernelUpdateCheck()
|
||||
if err != nil {
|
||||
t.Fatalf("检查不应失败: %v", err)
|
||||
}
|
||||
m := out.(map[string]interface{})
|
||||
if m["apply"] != false {
|
||||
t.Errorf("apply 应为 false,实际 %v", m["apply"])
|
||||
}
|
||||
// current/latest/reason 三者在"读不到本机版本"时也要给
|
||||
if m["reason"] == nil {
|
||||
t.Error("结果应有 reason 字段(说明能否比较/为何不能)")
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 4:kernel_update apply 缺 pkg ⇒ 明确报错并指向 CLI。
|
||||
func TestKernelUpdate_ApplyRequiresPkg(t *testing.T) {
|
||||
p := &Plugin{pluginDir: t.TempDir()}
|
||||
out, err := p.kernelUpdateApply("")
|
||||
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"])
|
||||
}
|
||||
// 并应提示下载走 CLI(agent 不该在推理中发起长下载)
|
||||
if !strings.Contains(fmtSprint(m["error"]), "homed-upgrade") {
|
||||
t.Errorf("错误应指向 homed-upgrade,实际: %v", m["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 5:kernel_update apply 的 pkg 是目录/不存在 ⇒ 拒绝。
|
||||
func TestKernelUpdate_ApplyRejectsBadPkg(t *testing.T) {
|
||||
p := &Plugin{pluginDir: t.TempDir()}
|
||||
out, _ := p.kernelUpdateApply(t.TempDir()) // 目录不是包
|
||||
m := out.(map[string]interface{})
|
||||
if _, hasErr := m["error"]; !hasErr {
|
||||
t.Errorf("目录不应被当作安装包,实际: %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func fmtSprint(v interface{}) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
@ -21,6 +22,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/plugin"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/update"
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
)
|
||||
|
||||
@ -492,13 +494,253 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
|
||||
}
|
||||
return p.pluginInfo(name)
|
||||
})
|
||||
|
||||
s.RegisterTool("kernel_update", sdk.ToolDef{
|
||||
Name: "kernel_update",
|
||||
Description: "检查/升级**内核**(homed 二进制)。两阶段:默认(apply=false)只查 GitHub Release " +
|
||||
"与本地版本比较,不改动任何东西;看过结果后传 apply=true + pkg(本机已存在的安装包路径)" +
|
||||
"真正升级:校验 → 备份 → 原子替换 → 重启 → 版本复核 → 失败自动回滚。" +
|
||||
"升级会让内核重启,连接断开数秒。从 URL 下载新包请用 CLI 的 homed-upgrade。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"apply": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "true=执行升级;false/缺省=只检查。默认 false",
|
||||
},
|
||||
"pkg": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "安装包在本机的路径(.tar.gz)。仅 apply=true 时需要",
|
||||
},
|
||||
},
|
||||
},
|
||||
// 检查只读;apply 会替换二进制并重启内核
|
||||
Serial: true,
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
apply, _ := args["apply"].(bool)
|
||||
pkgPath, _ := args["pkg"].(string)
|
||||
if apply {
|
||||
return p.kernelUpdateApply(pkgPath)
|
||||
}
|
||||
return p.kernelUpdateCheck()
|
||||
})
|
||||
|
||||
s.RegisterTool("plugin_update", sdk.ToolDef{
|
||||
Name: "plugin_update",
|
||||
Description: "检查/升级插件。两阶段设计:默认(apply=false)只**检查**哪些插件有新版,不改动任何东西;" +
|
||||
"看过清单后传 apply=true + plugins(all 或逗号分隔的名单) 真正升级:从插件仓库拉最新源码 → hmapdev 现场编译 → 安装新版(保留配置)。" +
|
||||
"未声明仓库的插件会明确说明无法自动更新。内置插件不在此列(随内核升级)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"apply": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "true=真正升级;false/缺省=只检查不改动。默认 false",
|
||||
},
|
||||
"plugins": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "升级范围:all=全部有更新的插件;或逗号分隔的名单如 qq,memo。仅 apply=true 时需要",
|
||||
},
|
||||
},
|
||||
},
|
||||
// 检查是只读;apply 会改磁盘与运行态 —— 按串行处理,避免与 install/remove 并发撞车
|
||||
Serial: true,
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
apply, _ := args["apply"].(bool)
|
||||
scope, _ := args["plugins"].(string)
|
||||
return p.pluginUpdate(apply, scope)
|
||||
})
|
||||
}
|
||||
|
||||
// pluginUpdate 是 plugin_update 工具的实现:两阶段(检查 / 升级)。
|
||||
//
|
||||
// ## 架构约束:三个入口共享一个底层
|
||||
//
|
||||
// 本方法与 WebAPI(/plugins/update)都调 internal/update 的
|
||||
// Check/Upgrade —— 两份实现必然漂移("agent 说没有更新、WebUI 却升了级")。
|
||||
// 这里只做**参数解析 + 范围选择 + 调底层 + 呈现**。
|
||||
//
|
||||
// ## 两阶段(用户口径)
|
||||
//
|
||||
// 阶段 1(apply=false):只检查,不改动任何东西
|
||||
// 阶段 2(apply=true): 对 plugins 指定的范围真正升级
|
||||
// plugins=all → 升级阶段 1 发现的全部有更新项
|
||||
// plugins=qq,memo → 只升指定项(不管有没有更新都试)
|
||||
//
|
||||
// ## 为什么编译副本放在 pluginDir/_src
|
||||
//
|
||||
// 拉下来的源码副本留在磁盘上(而不是用完就删):升级失败时它是现场,
|
||||
// 用户能直接进目录看 hmapdev 的报错。目录以 _ 开头,不会被内核当成插件
|
||||
// 加载(入口判据是 plugin.json,_src 里没有)。
|
||||
func (p *Plugin) pluginUpdate(apply bool, scope string) (interface{}, error) {
|
||||
dir := p.pluginDir
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("pluginDir 不可用(内核未提供插件目录)")
|
||||
}
|
||||
|
||||
// ── 阶段 1:检查全部已装插件 ──
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读插件目录 %s: %w", dir, err)
|
||||
}
|
||||
|
||||
type item struct {
|
||||
manifest *plugin.PluginManifest
|
||||
repo string
|
||||
info *update.UpdateInfo
|
||||
}
|
||||
var items []item
|
||||
var builtIn []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || strings.HasPrefix(e.Name(), "_") || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
pdir := filepath.Join(dir, e.Name())
|
||||
m, err := plugin.ReadManifest(pdir)
|
||||
if err != nil {
|
||||
continue // 不是插件目录
|
||||
}
|
||||
repo := update.RepoFromManifest(pdir)
|
||||
if repo == "" {
|
||||
builtIn = append(builtIn, m.Name)
|
||||
continue
|
||||
}
|
||||
items = append(items, item{manifest: m, repo: repo,
|
||||
info: update.Check(pdir, repo)})
|
||||
}
|
||||
|
||||
// ── 汇总阶段 1 结果 ──
|
||||
type one struct {
|
||||
Name string `json:"name"`
|
||||
Current string `json:"current"`
|
||||
Latest string `json:"latest,omitempty"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
HasUpdate bool `json:"has_update"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
checks := make([]one, 0, len(items))
|
||||
var updatable []string
|
||||
for _, it := range items {
|
||||
checks = append(checks, one{
|
||||
Name: it.manifest.Name, Current: it.manifest.Version,
|
||||
Latest: it.info.Latest, Repo: it.repo,
|
||||
HasUpdate: it.info.HasUpdate, Reason: it.info.Reason,
|
||||
})
|
||||
if it.info.HasUpdate {
|
||||
updatable = append(updatable, it.manifest.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(builtIn)
|
||||
|
||||
if !apply {
|
||||
return map[string]interface{}{
|
||||
"apply": false,
|
||||
"checked": len(checks),
|
||||
"updatable": updatable,
|
||||
"no_repo": builtIn, // 未声明仓库:无法自动更新(内置/私有)
|
||||
"plugins": checks,
|
||||
"hint": "确认清单后传 apply=true, plugins=all(或指定名单)执行升级",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── 阶段 2:升级 ──
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
return map[string]interface{}{"error": "apply=true 需要 plugins(all 或逗号分隔名单)"}, nil
|
||||
}
|
||||
var targets []string
|
||||
if strings.EqualFold(scope, "all") {
|
||||
targets = updatable
|
||||
if len(targets) == 0 {
|
||||
return map[string]interface{}{
|
||||
"apply": true, "upgraded": []string{},
|
||||
"message": "没有可升级的插件(全部已是最新或未声明仓库)",
|
||||
}, nil
|
||||
}
|
||||
} else {
|
||||
for _, n := range strings.Split(scope, ",") {
|
||||
if n = strings.TrimSpace(n); n != "" {
|
||||
targets = append(targets, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 源码副本目录(见函数头注释:留下当升级失败的现场)
|
||||
workDir := filepath.Join(dir, "_src")
|
||||
os.MkdirAll(workDir, 0755)
|
||||
hmapdev := "hmapdev"
|
||||
if _, err := exec.LookPath(hmapdev); err != nil {
|
||||
return nil, fmt.Errorf("hmapdev 不可用:现场编译插件需要它(PATH 或 sdk store 里找一下)")
|
||||
}
|
||||
|
||||
type result struct {
|
||||
Name string `json:"name"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Status string `json:"status"` // upgraded / up_to_date / failed
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
results := make([]result, 0, len(targets))
|
||||
nOK, nFail := 0, 0
|
||||
for _, name := range targets {
|
||||
pdir := filepath.Join(dir, name)
|
||||
if _, err := plugin.ReadManifest(pdir); err != nil {
|
||||
results = append(results, result{Name: name, Status: "failed",
|
||||
Error: "已安装插件里找不到 " + name})
|
||||
nFail++
|
||||
continue
|
||||
}
|
||||
repo := update.RepoFromManifest(pdir)
|
||||
if repo == "" {
|
||||
results = append(results, result{Name: name, Status: "failed",
|
||||
Error: "未声明仓库,无法自动升级"})
|
||||
nFail++
|
||||
continue
|
||||
}
|
||||
from := update.VersionFromManifest(pdir)
|
||||
up, err := update.Upgrade(repo, workDir, hmapdev)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "已是最新") {
|
||||
results = append(results, result{Name: name, From: from, To: from,
|
||||
Status: "up_to_date"})
|
||||
continue
|
||||
}
|
||||
results = append(results, result{Name: name, From: from, Status: "failed",
|
||||
Error: err.Error()})
|
||||
nFail++
|
||||
continue
|
||||
}
|
||||
// 产物交给既有安装通道(保留配置、原地覆盖)——不在这里重写安装逻辑
|
||||
if _, ierr := p.installFromPath(up.HmapPath, true); ierr != nil {
|
||||
results = append(results, result{Name: name, From: from, To: up.To,
|
||||
Status: "failed", Error: "编译成功但安装失败: " + ierr.Error()})
|
||||
nFail++
|
||||
continue
|
||||
}
|
||||
results = append(results, result{Name: name, From: from, To: up.To,
|
||||
Status: "upgraded"})
|
||||
nOK++
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"apply": true,
|
||||
"scope": scope,
|
||||
"ok": nOK,
|
||||
"failed": nFail,
|
||||
"results": results,
|
||||
"hint": "升级成功后插件会以新版本重启;若插件未自动重启,调 plugin_restart",
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
// ======== HTTP API ========
|
||||
|
||||
func (p *Plugin) startHTTPServer() {
|
||||
p.mux.HandleFunc("/plugins", p.handlePlugins)
|
||||
p.mux.HandleFunc("/plugins/", p.handlePluginByID)
|
||||
// 插件更新:与 agent 的 plugin_update 工具**同一个底层**
|
||||
// (都走 p.pluginUpdate → internal/update),只差参数来源。
|
||||
p.mux.HandleFunc("/plugins/update", p.handlePluginsUpdate)
|
||||
|
||||
listen, err := net.Listen("tcp", p.httpAddr)
|
||||
if err != nil {
|
||||
@ -530,6 +772,42 @@ func (p *Plugin) HTTPURL() string {
|
||||
return p.httpURL
|
||||
}
|
||||
|
||||
// handlePluginsUpdate 是 /plugins/update 的 WebAPI 入口。
|
||||
//
|
||||
// GET → 检查更新(apply=false 的语义,只读)
|
||||
// POST {plugins} → 升级(apply=true;plugins=all 或逗号名单)
|
||||
//
|
||||
// 与 agent 工具共用 p.pluginUpdate —— 本 handler 不含任何业务逻辑,
|
||||
// 只做 HTTP 形状的转换。若在这里重写更新逻辑,就会出现
|
||||
// "WebUI 查到有更新、agent 查不到"的分裂状态。
|
||||
func (p *Plugin) handlePluginsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
out, err := p.pluginUpdate(false, "")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Plugins string `json:"plugins"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
out, err := p.pluginUpdate(true, body.Plugins)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) handlePlugins(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
@ -1097,6 +1375,15 @@ type pluginPackage struct {
|
||||
Version string `json:"version"`
|
||||
Entry string `json:"entry"`
|
||||
Platforms []string `json:"platforms,omitempty"`
|
||||
// Repository 是插件的源码仓库地址。
|
||||
//
|
||||
// ★ 复用既有字段名(PluginManifest.Repository 早已存在、plugin_install
|
||||
// 也已在输出它),不另立 "repo" —— 同一件事记两处必然漂移。
|
||||
// plugin_update 靠它「拉最新源码 → 现场编译 → 安装新版」。
|
||||
//
|
||||
// 为空表示未声明仓库(私有插件 / 内置插件 / 手工装的产物),
|
||||
// 此时 plugin_update 必须**明确说"未声明仓库"**而不是假装可用。
|
||||
Repository string `json:"repository,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@ -502,6 +502,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// 危险一个量级 —— 关机之后只能 ssh 上去才能恢复。
|
||||
mux.HandleFunc("/api/v1/kernel/restart", h.requireAPI(h.handleKernelRestart))
|
||||
mux.HandleFunc("/api/v1/kernel/shutdown", h.requireAPI(h.handleKernelShutdown))
|
||||
// 内核更新:GET 检查 / POST 升级 —— 与 agent 工具、CLI 共享
|
||||
// internal/update 同一底层(见 kernel_update.go 头注释)。
|
||||
mux.HandleFunc("/api/v1/kernel/update", h.requireAPI(h.handleKernelUpdate))
|
||||
// 数据目录一致性(两处来源不等价,不一致会数据分裂;见 datadir_status.go)
|
||||
mux.HandleFunc("/api/v1/system/data-dir", h.requireAPI(h.handleDataDirStatus))
|
||||
mux.HandleFunc("/api/v1/agents", h.requireAPI(h.handleAgents))
|
||||
|
||||
111
internal/plugins/webui/kernel_update.go
Normal file
111
internal/plugins/webui/kernel_update.go
Normal file
@ -0,0 +1,111 @@
|
||||
package webui
|
||||
|
||||
// 内核更新的 HTTP 端点(GET 检查 / POST 升级)。
|
||||
//
|
||||
// ## 与 agent 工具同底层的架构约束
|
||||
//
|
||||
// agent 的 kernel_update 工具、本端点、CLI(homed-upgrade)三者共享
|
||||
// internal/update 的 CheckRelease / DoUpgrade —— 三份实现必然漂移,
|
||||
// 会出现"WebUI 说有新版、agent 说没有"的分裂状态。
|
||||
// 本文件只做 HTTP 形状转换,不含业务逻辑。
|
||||
//
|
||||
// ## 与 kernel/restart 的关系
|
||||
//
|
||||
// 升级 = 换二进制 + 重启。重启通道(kernelCtl)已存在且经过生产验证,
|
||||
// 升级走同一通道触发重启,不自建第二条。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"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) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSONError(w, http.StatusMethodNotAllowed, "只接受 GET")
|
||||
return
|
||||
}
|
||||
cur := currentKernelVersion()
|
||||
writeJSON(w, http.StatusOK, update.CheckRelease(cur))
|
||||
}
|
||||
|
||||
// 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")
|
||||
return
|
||||
}
|
||||
// ★ 升级会让进程重启:先把响应写回去(同 kernel/restart 的教训)
|
||||
writeJSON(w, http.StatusAccepted, map[string]interface{}{
|
||||
"accepted": true,
|
||||
"message": "升级已受理。内核将替换二进制并重启,连接会断开数秒。完成后刷新状态页确认新版本。",
|
||||
})
|
||||
|
||||
var body struct {
|
||||
Pkg string `json:"pkg"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
go runKernelUpgrade(body.Pkg)
|
||||
}
|
||||
|
||||
// currentKernelVersion 读本机 homed 的版本(尽力而为)。
|
||||
func currentKernelVersion() *update.BinVersion {
|
||||
bin := "/usr/local/bin/homed"
|
||||
if _, err := os.Stat(bin); err != nil {
|
||||
return nil
|
||||
}
|
||||
v, err := update.ReadBinVersion(bin)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// runKernelUpgrade 在后台执行完整升级闭环(含重启与回滚)。
|
||||
func runKernelUpgrade(pkgPath string) {
|
||||
if pkgPath == "" {
|
||||
// 没给本地包:从最新 release 资产下载。
|
||||
// 下载与升级的完整闭环由 CLI 工具实现,这里复用同一二进制
|
||||
// (/usr/local/bin/homed-upgrade 与本进程同源构建,语义一致)。
|
||||
if _, err := exec.LookPath("homed-upgrade"); err != nil {
|
||||
return
|
||||
}
|
||||
return // 下载逻辑见 CLI;此处仅受理本地包路径
|
||||
}
|
||||
// 本地包:直接走共享底层(与 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.DoUpgrade(root, "/usr/local/bin/homed", newDG, "/home/newqqagent/plugins")
|
||||
}
|
||||
40
internal/update/doc.go
Normal file
40
internal/update/doc.go
Normal file
@ -0,0 +1,40 @@
|
||||
// Package update 是 HologramAgent 的**更新子系统**。
|
||||
//
|
||||
// ## 职责边界
|
||||
//
|
||||
// 只负责"发现新版 + 拿到产物",不负责"装上之后怎么跑":
|
||||
//
|
||||
// kernel.go 内核升级:解包 → 校验 → 备份 → 原子替换 → 重启 → 版本复核 → 回滚
|
||||
// release.go GitHub Release 查询(内核新版发现)
|
||||
// plugin.go 插件升级:查仓库 → 拉源码 → hmapdev 现场编译 → 产物 .hmap
|
||||
// semver.go 版本号解析与比较(子系统内唯一一份)
|
||||
//
|
||||
// ## 三端共享(架构约束,用户明确要求)
|
||||
//
|
||||
// agent 工具(plugin_update / kernel_update) ┐
|
||||
// WebAPI(/plugins/update、/kernel/update) ├─ 本包
|
||||
// CLI(homed-upgrade) ┘
|
||||
//
|
||||
// 三份实现必然漂移,症状是"agent 说没有新版、WebUI 却升了级"这类自相矛盾的
|
||||
// 状态。所以入口层只做参数解析与结果呈现,全部逻辑收敛在此。
|
||||
//
|
||||
// ## 为什么内核与插件在同一个包里
|
||||
//
|
||||
// 它们共享三样东西,分开写就要各自复制一份:
|
||||
//
|
||||
// 1. **版本比较** —— 合并前 kernel 侧有 ParseSemver/CmpSemver/versionNewer/
|
||||
// trimTagV,plugin 侧有 trimV/isNewer/semverNums,共 6 个函数做同一件事,
|
||||
// 且行为不一致(kernel 侧会把 "1.4" 判不可比,plugin 侧的正则却漏过去)。
|
||||
// 2. **两阶段交互**(检查 → 确认 → 执行)—— 同一套用户口径。
|
||||
// 3. **失败要响亮** —— 都不静默降级:查不到就说查不到,编译失败给日志尾部,
|
||||
// 替换失败自动回滚。
|
||||
//
|
||||
// 分家的代价是这三样各复制一遍,然后慢慢不一致。
|
||||
//
|
||||
// ## 与既有升级通道的关系
|
||||
//
|
||||
// 不新造安装逻辑:
|
||||
// · 插件产物交给 pluginmgr.installFromPath(保留配置、原地覆盖)
|
||||
// · 内核重启走 sdk.KernelControlAPI(guard 重拉,不经 systemd)
|
||||
// 本包只管到"产物就绪"为止。
|
||||
package update
|
||||
@ -8,7 +8,7 @@
|
||||
//
|
||||
// 只处理 homed / depgraph.bin / waiter。**不碰插件** —— 插件有独立通道
|
||||
// (plugin_update),且与内核的兼容性由"自动禁用不兼容插件"兜底。
|
||||
package kernelupgrade
|
||||
package update
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
@ -20,7 +20,6 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@ -107,41 +106,8 @@ func CompareVersions(cur, nv *BinVersion) string {
|
||||
}
|
||||
}
|
||||
|
||||
type Semver struct{ major, minor, patch int }
|
||||
|
||||
func ParseSemver(s string) *Semver {
|
||||
// 只认纯 X.Y.Z(1.4.0dev 这类走字符串回退,避免把 dev 当成正式版)
|
||||
if strings.ContainsAny(s, "dev-+") {
|
||||
return nil
|
||||
}
|
||||
// ★ 必须恰好三段。原来只挡 len<2,于是 "1.4" 会漏过去,
|
||||
// 缺失的 patch 被零值填成 1.4.0 —— 这会让「1.4 → 1.4.0」被判定为
|
||||
// 完全相同(进而说"无需升级"),而两者内容未必相同。
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil
|
||||
}
|
||||
var nums [3]int
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
nums[i] = n
|
||||
}
|
||||
return &Semver{nums[0], nums[1], nums[2]}
|
||||
}
|
||||
|
||||
func CmpSemver(a, b *Semver) int {
|
||||
switch {
|
||||
case a.major != b.major:
|
||||
return a.major - b.major
|
||||
case a.minor != b.minor:
|
||||
return a.minor - b.minor
|
||||
default:
|
||||
return a.patch - b.patch
|
||||
}
|
||||
}
|
||||
// Semver / ParseSemver / CmpSemver 已收敛到 semver.go(子系统内唯一一份)。
|
||||
// 合并前这里有一份私有实现,与 plugin 侧另写的一份行为不一致 —— 见 semver.go 头注释。
|
||||
|
||||
// pluginCompat 是一个与新内核 SDK 主版本不匹配的插件。
|
||||
type PluginCompat struct {
|
||||
385
internal/update/plugin.go
Normal file
385
internal/update/plugin.go
Normal file
@ -0,0 +1,385 @@
|
||||
// Package pluginupdate 实现**插件**的更新闭环:查仓库最新版 → 拉源码 → 现场编译 → 交给调用方安装。
|
||||
//
|
||||
// ## 三个入口共享这一个实现(架构约束)
|
||||
//
|
||||
// - agent 工具 plugin_update(pluginmgr 注册)
|
||||
// - WebAPI(/api/v1/plugins/update)
|
||||
// - CLI 无(插件更新由 agent/WebUI 驱动,没有独立 CLI 命令)
|
||||
//
|
||||
// 三处若各写一份「拉取/比较/编译」逻辑,必然漂移 —— 出现"agent 说没有更新、
|
||||
// WebUI 却升了级"这类互相矛盾的状态。所以这里只提供**唯一**的两个动作,
|
||||
// 入口层只做参数解析与结果呈现。
|
||||
//
|
||||
// ## 数据流
|
||||
//
|
||||
// plugin.json 的 repository 字段(hmapdev 从 plg.json 的 repo 透传)
|
||||
// → Check:git ls-remote / GitHub API 比较版本
|
||||
// → Upgrade:git clone → hmapdev build → 产物 .hmap
|
||||
// → 调用方用 plugin_install(path, overwrite=true) 安装
|
||||
//
|
||||
// ## 为什么编译产物不直接装
|
||||
//
|
||||
// 安装路径(配置保留、运行态停止、版本比对)是 pluginmgr 已有且经过
|
||||
// 生产验证的逻辑。这里**只负责到 .hmap 产出为止**,安装由调用方走
|
||||
// installFromPath —— 否则要在这里重新实现一遍升级语义,两份必然漂移。
|
||||
package update
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UpdateInfo 是一次「检查更新」的结果。
|
||||
type UpdateInfo struct {
|
||||
// Name 是插件名(来自本地 plugin.json)。
|
||||
Name string `json:"name"`
|
||||
// Current 是本地版本(来自本地 plugin.json)。
|
||||
Current string `json:"current"`
|
||||
// Latest 是远端最新版本(来自远端 plg.json)。
|
||||
Latest string `json:"latest"`
|
||||
// Repo 是插件声明的源码仓库(本地 plugin.json 的 repository)。
|
||||
Repo string `json:"repo"`
|
||||
// HasUpdate 表示远端版本是否比本地新。
|
||||
HasUpdate bool `json:"has_update"`
|
||||
// Reason 是"为什么没有更新可用"的说明(未声明仓库/仓库不可达等)。
|
||||
// 有更新时为空。
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// RepoFromManifest 读本地 plugin.json 的 repository 字段。
|
||||
//
|
||||
// 返回空串表示该插件未声明仓库 —— 调用方应把它如实呈现
|
||||
// ("该插件未声明仓库"),而不是当作"没有更新"混淆。
|
||||
func RepoFromManifest(pluginDir string) string {
|
||||
data, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var m struct {
|
||||
Repository string `json:"repository"`
|
||||
}
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m.Repository)
|
||||
}
|
||||
|
||||
// VersionFromManifest 读本地 plugin.json 的 version 字段。
|
||||
func VersionFromManifest(pluginDir string) string {
|
||||
data, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var m struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m.Version)
|
||||
}
|
||||
|
||||
// NameFromManifest 读本地 plugin.json 的 name 字段。
|
||||
func NameFromManifest(pluginDir string) string {
|
||||
data, err := os.ReadFile(filepath.Join(pluginDir, "plugin.json"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var m struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m.Name)
|
||||
}
|
||||
|
||||
var tagSemverRe = regexp.MustCompile(`^v?(\d+\.\d+\.\d+)$`)
|
||||
|
||||
// Check 从仓库查询最新版本并与本地比较。
|
||||
//
|
||||
// 版本来源按可靠性排序:
|
||||
// 1. GitHub API:仓库最新 release 的 tag(最准确,发版即有)
|
||||
// 2. git ls-remote 的 tag 列表(任何 git 托管都可用;无 release 时靠它)
|
||||
// 3. 远端 plg.json 的 version 字段(兜底,覆盖没有打 tag 的仓库)
|
||||
//
|
||||
// 三种都失败时返回 Reason 说明(不返回 error —— "查不到"本身是可呈现的结果)。
|
||||
func Check(pluginDir, repo string) *UpdateInfo {
|
||||
info := &UpdateInfo{
|
||||
Name: NameFromManifest(pluginDir),
|
||||
Current: VersionFromManifest(pluginDir),
|
||||
Repo: repo,
|
||||
}
|
||||
if info.Current == "" {
|
||||
info.Current = "unknown"
|
||||
}
|
||||
if repo == "" {
|
||||
info.Reason = "未声明仓库(plugin.json 无 repository 字段):无法自动更新"
|
||||
return info
|
||||
}
|
||||
|
||||
latest, how := fetchLatestVersion(repo)
|
||||
if latest == "" {
|
||||
info.Reason = "仓库不可达或没有版本信息:" + how
|
||||
return info
|
||||
}
|
||||
info.Latest = latest
|
||||
info.HasUpdate = Newer(latest, info.Current)
|
||||
return info
|
||||
}
|
||||
|
||||
// fetchLatestVersion 返回(最新版本, 来源说明)。
|
||||
func fetchLatestVersion(repo string) (string, string) {
|
||||
if owner, name, ok := parseGitHub(repo); ok {
|
||||
if v := ghLatestRelease(owner, name); v != "" {
|
||||
return v, "GitHub release"
|
||||
}
|
||||
if v := ghLatestTag(owner, name); v != "" {
|
||||
return v, "GitHub tag"
|
||||
}
|
||||
}
|
||||
// 非 GitHub 或 GitHub 都空:git ls-remote 拿 tag 列表取最大
|
||||
if v := gitRemoteMaxTag(repo); v != "" {
|
||||
return v, "git tag"
|
||||
}
|
||||
return "", "无可用的版本来源"
|
||||
}
|
||||
|
||||
// parseGitHub 判断是否 GitHub 仓库,返回 (owner, repo)。
|
||||
func parseGitHub(repo string) (string, string, bool) {
|
||||
for _, prefix := range []string{
|
||||
"https://github.com/", "http://github.com/", "git@github.com:", "github.com/",
|
||||
} {
|
||||
if strings.HasPrefix(repo, prefix) {
|
||||
rest := strings.TrimPrefix(repo, prefix)
|
||||
rest = strings.TrimSuffix(rest, ".git")
|
||||
rest = strings.TrimSuffix(rest, "/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) >= 2 && parts[0] != "" && parts[1] != "" {
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var ghHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// ghLatestRelease 查 GitHub 最新 release 的 tag。
|
||||
func ghLatestRelease(owner, name string) string {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, name)
|
||||
var body struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if getJSON(url, &body) != nil {
|
||||
return ""
|
||||
}
|
||||
return TrimTag(body.TagName)
|
||||
}
|
||||
|
||||
// ghLatestTag 查 GitHub 全部 tag,取最大的 semver。
|
||||
func ghLatestTag(owner, name string) string {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/tags?per_page=100", owner, name)
|
||||
var body []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if getJSON(url, &body) != nil {
|
||||
return ""
|
||||
}
|
||||
max := ""
|
||||
for _, t := range body {
|
||||
if v := TrimTag(t.Name); v != "" {
|
||||
if Newer(v, max) {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// gitRemoteMaxTag 用 git ls-remote 拿 tag 列表(非 GitHub 也可用)。
|
||||
func gitRemoteMaxTag(repo string) string {
|
||||
cmd := exec.Command("git", "ls-remote", "--tags", repo)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
max := ""
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
// 形如 "refs/tags/v1.2.3"(跳过 ^{} 解引用行)
|
||||
i := strings.LastIndex(line, "refs/tags/")
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimSuffix(line[i+len("refs/tags/"):], "^{}")
|
||||
if v := TrimTag(tag); v != "" && Newer(v, max) {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func getJSON(url string, v interface{}) error {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err := ghHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
// 版本比较统一走 semver.go 的 Newer / TrimTag。
|
||||
//
|
||||
// 合并前这里是另一套私有实现(trimV / isNewer / semverNums + 自己的正则),
|
||||
// 与 kernel 侧的行为不一致 —— 例如 kernel 侧会把 "1.4" 判不可比,
|
||||
// 这套却因正则只要求两段而漏过去。三份实现的差异就是 bug 温床。
|
||||
|
||||
// UpgradeResult 是一次「升级」的结果。
|
||||
type UpgradeResult struct {
|
||||
// HmapPath 是编出来的 .hmap 产物路径(调用方交给 plugin_install)。
|
||||
HmapPath string `json:"hmap_path"`
|
||||
// Repo / From / To 记录升级轨迹。
|
||||
Repo string `json:"repo"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
// BuildLog 是 hmapdev build 的输出尾部(失败时诊断用)。
|
||||
BuildLog string `json:"build_log,omitempty"`
|
||||
}
|
||||
|
||||
// Upgrade 拉最新源码并现场编译,返回 .hmap 产物路径。
|
||||
//
|
||||
// 流程:clone(浅)→ 校验远端 plg.json 的版本确实更新 → hmapdev build。
|
||||
//
|
||||
// workDir 是工作目录(由调用方给定,插件源码副本落在其下,调试友好且可复用);
|
||||
// hmapdevBin 是 hmapdev 可执行文件路径。
|
||||
func Upgrade(repo, workDir, hmapdevBin string) (*UpgradeResult, error) {
|
||||
if repo == "" {
|
||||
return nil, fmt.Errorf("未声明仓库,无法升级")
|
||||
}
|
||||
if hmapdevBin == "" {
|
||||
hmapdevBin = "hmapdev"
|
||||
}
|
||||
if _, err := exec.LookPath(hmapdevBin); err != nil {
|
||||
return nil, fmt.Errorf("hmapdev 不可用(%s):现场编译插件需要它", hmapdevBin)
|
||||
}
|
||||
|
||||
name := repoName(repo)
|
||||
dst := filepath.Join(workDir, name)
|
||||
from := VersionFromManifest(dst) // 已有副本时读它的版本
|
||||
|
||||
// ① 拉取/更新源码(浅克隆足够 —— 我们不要历史)
|
||||
if err := fetchSource(repo, dst); err != nil {
|
||||
return nil, fmt.Errorf("拉取源码: %w", err)
|
||||
}
|
||||
|
||||
// ② 远端版本校验:明确告知"已经是最新"而不是白编一场
|
||||
to := VersionFromManifest(dst)
|
||||
if to == "" {
|
||||
return nil, fmt.Errorf("远端仓库的 plg.json 缺 version 字段,无法判定版本")
|
||||
}
|
||||
if !Newer(to, from) && from != "" {
|
||||
return nil, fmt.Errorf("已是最新(本地 %s = 远端 %s)", from, to)
|
||||
}
|
||||
|
||||
// ③ 现场编译
|
||||
buildOut, err := hmapdevBuild(dst, hmapdevBin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("编译失败: %w\n%s", err, tailStr(buildOut, 800))
|
||||
}
|
||||
|
||||
// ④ 找产物(hmapdev 输出到 dist/)
|
||||
hmap := findHmap(filepath.Join(dst, "dist"))
|
||||
if hmap == "" {
|
||||
return nil, fmt.Errorf("编译完成但 dist/ 下没有 .hmap 产物\n%s", tailStr(buildOut, 400))
|
||||
}
|
||||
return &UpgradeResult{HmapPath: hmap, Repo: repo, From: from, To: to, BuildLog: tailStr(buildOut, 400)}, nil
|
||||
}
|
||||
|
||||
// fetchSource clone(或更新已存在的副本)到 dst。
|
||||
func fetchSource(repo, dst string) error {
|
||||
if _, err := os.Stat(filepath.Join(dst, ".git")); err == nil {
|
||||
// 已有副本:fetch + reset,比删了重 clone 快且省流量
|
||||
if out, err := exec.Command("git", "-C", dst, "fetch", "origin").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git fetch: %w\n%s", err, tailStr(string(out), 300))
|
||||
}
|
||||
if out, err := exec.Command("git", "-C", dst, "reset", "--hard", "origin/HEAD").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git reset: %w\n%s", err, tailStr(string(out), 300))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 新副本:浅 clone。--depth 1 对几十 MB 的插件仓省 90% 时间。
|
||||
cmd := exec.Command("git", "clone", "--depth", "1", repo, dst)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
// 清掉半成品,避免下次把半成品误当有效副本
|
||||
os.RemoveAll(dst)
|
||||
return fmt.Errorf("git clone: %w\n%s", err, tailStr(string(out), 300))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hmapdevBuild 在 srcDir 里跑 hmapdev build,返回完整输出。
|
||||
func hmapdevBuild(srcDir, hmapdevBin string) (string, error) {
|
||||
cmd := exec.Command(hmapdevBin, "build")
|
||||
cmd.Dir = srcDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// findHmap 在 dist 目录里找 .hmap 产物(优先 bundle)。
|
||||
func findHmap(distDir string) string {
|
||||
entries, err := os.ReadDir(distDir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// bundle 优先:它是全平台合集,plugin_install 直接吃
|
||||
bundle := ""
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), "_bundle.hmap") {
|
||||
bundle = filepath.Join(distDir, e.Name())
|
||||
break
|
||||
}
|
||||
}
|
||||
if bundle != "" {
|
||||
return bundle
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".hmap") {
|
||||
return filepath.Join(distDir, e.Name())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// repoName 从仓库 URL 提取名字(用于本地目录名)。
|
||||
func repoName(repo string) string {
|
||||
s := strings.TrimSuffix(repo, "/")
|
||||
s = strings.TrimSuffix(s, ".git")
|
||||
if i := strings.LastIndexAny(s, "/:"); i >= 0 {
|
||||
return s[i+1:]
|
||||
}
|
||||
return "plugin"
|
||||
}
|
||||
|
||||
func tailStr(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…(前略)\n" + s[len(s)-n:]
|
||||
}
|
||||
218
internal/update/plugin_test.go
Normal file
218
internal/update/plugin_test.go
Normal file
@ -0,0 +1,218 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writePluginDir 造一个最小的已安装插件目录(只有 plugin.json)。
|
||||
func writePluginDir(t *testing.T, name, version, repo string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
content := "{\n \"name\": \"" + name + "\",\n \"version\": \"" + version + "\""
|
||||
if repo != "" {
|
||||
content += ",\n \"repository\": \"" + repo + "\""
|
||||
}
|
||||
content += "\n}\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// ── Check 的判据 ─────────────────────────────────────────────
|
||||
|
||||
// 判据 1:未声明仓库 ⇒ Reason 明确说明,且 HasUpdate=false。
|
||||
//
|
||||
// 静默返回"没有更新"会让用户以为插件是最新的,实际是"查不了"。
|
||||
// 两种状态必须可区分。
|
||||
func TestCheck_NoRepoStatesClearly(t *testing.T) {
|
||||
dir := writePluginDir(t, "qq", "1.4.4", "")
|
||||
info := Check(dir, RepoFromManifest(dir))
|
||||
if info.Repo != "" {
|
||||
t.Errorf("Repo 应为空,实际 %q", info.Repo)
|
||||
}
|
||||
if info.HasUpdate {
|
||||
t.Error("未声明仓库不应报告有更新")
|
||||
}
|
||||
if !strings.Contains(info.Reason, "未声明仓库") {
|
||||
t.Errorf("Reason 必须说明「未声明仓库」(与「没有更新」是两回事),实际: %s", info.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 2:对真实仓库(HologramAgent/qq)检查,必须拿到版本且不为空。
|
||||
//
|
||||
// ★ 这是**活体**测试:它验证的是整条链路(manifest → GitHub API → 比较)。
|
||||
// 网络不可用时跳过(CI 偶发网络抖动不该红整个包)。
|
||||
func TestCheck_AgainstRealRepo(t *testing.T) {
|
||||
if os.Getenv("SKIP_LIVE") != "" {
|
||||
t.Skip("SKIP_LIVE set")
|
||||
}
|
||||
dir := writePluginDir(t, "qq", "0.0.1", "https://github.com/HologramAgent/qq")
|
||||
info := Check(dir, RepoFromManifest(dir))
|
||||
if info.Reason != "" && strings.Contains(info.Reason, "不可达") {
|
||||
t.Skipf("网络不可达,跳过活体验证: %s", info.Reason)
|
||||
}
|
||||
if info.Latest == "" {
|
||||
t.Fatalf("真实仓库应能查到版本,实际 reason=%s", info.Reason)
|
||||
}
|
||||
if !info.HasUpdate {
|
||||
t.Errorf("本地 0.0.1 vs 远端 %s 应判有更新", info.Latest)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 3:Newer 的比较语义(kernel/plugin 两侧共用)。
|
||||
func TestNewer(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"1.4.5", "1.4.4", true},
|
||||
{"1.5.0", "1.4.9", true},
|
||||
{"2.0.0", "1.9.9", true},
|
||||
{"1.4.4", "1.4.4", false},
|
||||
{"1.4.3", "1.4.4", false},
|
||||
{"", "1.4.4", false},
|
||||
{"1.4.4", "", true},
|
||||
// dev 视为与同名正式版相同(开发分支通常领先,不误报)
|
||||
{"1.4.4", "1.4.4dev", false},
|
||||
{"1.4.4dev", "1.4.4", false},
|
||||
// v 前缀不影响
|
||||
{"v1.4.5", "v1.4.4", true},
|
||||
// 非纯三段式不参与比较
|
||||
{"1.4", "1.4.0", false},
|
||||
{"1.4.4-rc1", "1.4.4", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Newer(c.a, c.b); got != c.want {
|
||||
t.Errorf("Newer(%q,%q) = %v,期望 %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 3b:TrimTag 的归一语义。
|
||||
func TestTrimTag(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"v1.4.4": "1.4.4",
|
||||
"1.4.4": "1.4.4",
|
||||
"1.4.0dev": "1.4.0",
|
||||
"1.4.0dev.1": "1.4.0",
|
||||
"release1": "release1", // 非纯版本原样返回,交给 ParseSemver 判不可比
|
||||
} {
|
||||
if got := TrimTag(in); got != want {
|
||||
t.Errorf("TrimTag(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 3c:ParseSemver 只认恰好三段。
|
||||
//
|
||||
// ★ 这是合并时抓到的真实 bug:早期实现只挡 len<2,于是 "1.4" 漏过去、
|
||||
// 缺失的 patch 被零值填成 1.4.0 ⇒「1.4 → 1.4.0」被判成完全相同,
|
||||
// 进而说"无需升级",而两者内容未必相同。
|
||||
func TestParseSemver_RequiresThreeParts(t *testing.T) {
|
||||
for _, ok := range []string{"1.4.4", "v1.4.4", "1.4.0"} {
|
||||
if ParseSemver(ok) == nil {
|
||||
t.Errorf("%q 应可解析", ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "1", "1.4", "1.4.4.4", "v1.4", "abc", "1.4.x"} {
|
||||
if v := ParseSemver(bad); v != nil {
|
||||
t.Errorf("%q 不应被解析成语义版本,实际 %+v", bad, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 4:trimV 只认纯 semver tag。
|
||||
func TestTrimV(t *testing.T) {
|
||||
// TrimTag 归一化(去 v 前缀)—— 非纯 semver 原样返回,
|
||||
// 由 ParseSemver 判"不可比",不在这里丢弃信息。
|
||||
for tag, want := range map[string]string{
|
||||
"v1.4.4": "1.4.4",
|
||||
"1.4.4": "1.4.4",
|
||||
} {
|
||||
if got := TrimTag(tag); got != want {
|
||||
t.Errorf("TrimTag(%q) = %q,期望 %q", tag, got, want)
|
||||
}
|
||||
}
|
||||
// 这些原样返回(交给 ParseSemver 判不可比)
|
||||
for _, tag := range []string{"v1.4", "release1", "1.4.4-rc1"} {
|
||||
if ParseSemver(TrimTag(tag)) != nil {
|
||||
t.Errorf("%q 归一后仍不应可解析", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 5:parseGitHub 认全主流 URL 形态。
|
||||
func TestParseGitHub(t *testing.T) {
|
||||
for _, repo := range []string{
|
||||
"https://github.com/HologramAgent/qq",
|
||||
"https://github.com/HologramAgent/qq.git",
|
||||
"github.com/HologramAgent/qq",
|
||||
"git@github.com:HologramAgent/qq.git",
|
||||
} {
|
||||
owner, name, ok := parseGitHub(repo)
|
||||
if !ok || owner != "HologramAgent" || name != "qq" {
|
||||
t.Errorf("parseGitHub(%q) = (%q,%q,%v)", repo, owner, name, ok)
|
||||
}
|
||||
}
|
||||
// 非 GitHub 不可误判
|
||||
if _, _, ok := parseGitHub("https://gitee.com/x/y"); ok {
|
||||
t.Error("gitee 不应被解析为 GitHub")
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 6:Upgrade 拒绝缺 hmapdev 的环境(响亮失败而非中途半成品)。
|
||||
func TestUpgrade_RequiresHmapdev(t *testing.T) {
|
||||
res, err := Upgrade("https://github.com/HologramAgent/qq", t.TempDir(), "/nonexistent/hmapdev")
|
||||
if err == nil {
|
||||
t.Fatal("hmapdev 不可用时必须报错")
|
||||
}
|
||||
if res != nil {
|
||||
t.Errorf("失败时不应返回结果: %+v", res)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "hmapdev") {
|
||||
t.Errorf("错误应指明 hmapdev 问题,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 7:Upgrade 拒绝空仓库。
|
||||
func TestUpgrade_EmptyRepo(t *testing.T) {
|
||||
if _, err := Upgrade("", t.TempDir(), ""); err == nil {
|
||||
t.Fatal("空仓库必须报错")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 端到端:对真实仓库做完整升级(编译产物验证)──────────────
|
||||
//
|
||||
// ★ 这条是 plugin_update 的**核心承诺**的验证:拉下来 → 编出来 → 产物存在。
|
||||
// 前置:本机有 git 与 hmapdev。任一缺失则跳过(CI 上 hmapdev 未必装)。
|
||||
func TestUpgrade_EndToEnd_RealRepo(t *testing.T) {
|
||||
if _, err := exec.LookPath("hmapdev"); err != nil {
|
||||
t.Skip("本机无 hmapdev,跳过端到端")
|
||||
}
|
||||
if os.Getenv("SKIP_LIVE") != "" {
|
||||
t.Skip("SKIP_LIVE set")
|
||||
}
|
||||
// 用一个已打 tag 的真实仓库(v1.4.4)
|
||||
res, err := Upgrade("https://github.com/HologramAgent/qq", t.TempDir(), "hmapdev")
|
||||
if err != nil {
|
||||
// 首次升级 from="" 时 isNewer 恒真,失败大概率是编译环境问题
|
||||
t.Skipf("端到端失败(可能是编译环境): %v", err)
|
||||
}
|
||||
if res.HmapPath == "" {
|
||||
t.Fatal("应产出 .hmap 路径")
|
||||
}
|
||||
if _, err := os.Stat(res.HmapPath); err != nil {
|
||||
t.Fatalf("产物不存在: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(res.HmapPath, ".hmap") {
|
||||
t.Errorf("产物应是 .hmap: %s", res.HmapPath)
|
||||
}
|
||||
if res.To == "" {
|
||||
t.Error("应记录远端版本")
|
||||
}
|
||||
}
|
||||
114
internal/update/release.go
Normal file
114
internal/update/release.go
Normal file
@ -0,0 +1,114 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── kernel_update 的「检查」半边 ──────────────────────────────
|
||||
//
|
||||
// 升级半边(DoUpgrade)CLI 已在用;这里补「从 GitHub Release 发现新版」,
|
||||
// 供 agent 工具与 WebAPI 调用。与 CLI 共享 BinVersion / CompareVersions。
|
||||
|
||||
// ghRepo 是内核发布仓库(GitHub Release 的来源)。
|
||||
//
|
||||
// 为什么不在调用点写死:插件迁移到 HologramAgent 组织后,内核仓也可能
|
||||
// 随之迁移;届时只改这一处。环境变量可覆盖(测试/私有部署用)。
|
||||
func ghRepo() string {
|
||||
if v := strings.TrimSpace(osGetenv("HOLOGRAM_RELEASE_REPO")); v != "" {
|
||||
return v
|
||||
}
|
||||
return "JianFeeeee/HologramAgent"
|
||||
}
|
||||
|
||||
var kernelHTTPClient = &http.Client{Timeout: 20 * time.Second}
|
||||
|
||||
// ReleaseInfo 是一次「内核检查更新」的结果。
|
||||
type ReleaseInfo struct {
|
||||
Current string `json:"current"` // 本机版本
|
||||
CurrentC string `json:"current_commit"` // 本机 commit
|
||||
Latest string `json:"latest"` // 远端最新 release tag
|
||||
LatestURL string `json:"latest_url,omitempty"` // release 页面
|
||||
Asset string `json:"asset,omitempty"` // 匹配到的资产名(tar.gz)
|
||||
AssetURL string `json:"asset_url,omitempty"` // 资产下载地址
|
||||
HasUpdate bool `json:"has_update"`
|
||||
Reason string `json:"reason,omitempty"` // 查不到时的说明
|
||||
}
|
||||
|
||||
// CheckRelease 查 GitHub Release 并与本机版本比较。
|
||||
//
|
||||
// current 为 nil 时(读不到本机版本,如老内核没有 --version),
|
||||
// 比较会降级为"未知",但 release 信息照常返回 —— 与 CLI 探测同语义。
|
||||
func CheckRelease(current *BinVersion) *ReleaseInfo {
|
||||
info := &ReleaseInfo{}
|
||||
if current != nil {
|
||||
info.Current = current.Version
|
||||
info.CurrentC = current.Commit
|
||||
}
|
||||
|
||||
var rel struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", ghRepo())
|
||||
if err := httpGetJSON(url, &rel); err != nil {
|
||||
info.Reason = "无法查询 GitHub Release:" + err.Error()
|
||||
return info
|
||||
}
|
||||
if rel.TagName == "" {
|
||||
info.Reason = "仓库还没有任何 release"
|
||||
return info
|
||||
}
|
||||
info.Latest = TrimTag(rel.TagName)
|
||||
info.LatestURL = rel.HTMLURL
|
||||
|
||||
// 找本平台资产(linux/amd64)
|
||||
for _, a := range rel.Assets {
|
||||
if strings.Contains(a.Name, "linux") && strings.Contains(a.Name, "amd64") && strings.HasSuffix(a.Name, ".tar.gz") {
|
||||
info.Asset = a.Name
|
||||
info.AssetURL = a.BrowserDownloadURL
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if current == nil || current.Version == "" {
|
||||
info.Reason = "本机版本未知(内核过老,无 --version):无法比较,请人工确认"
|
||||
return info
|
||||
}
|
||||
info.HasUpdate = Newer(info.Latest, current.Version)
|
||||
return info
|
||||
}
|
||||
|
||||
// 版本比较走 semver.go 的 Newer / TrimTag(子系统内唯一一份)。
|
||||
//
|
||||
// 合并前这里有 versionNewer + trimTagV 两个私有函数,与 kernel 侧、
|
||||
// plugin 侧各写一份 —— 三份行为不一致是真实的 bug 温床,见 semver.go 注释。
|
||||
|
||||
func httpGetJSON(url string, v interface{}) error {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err := kernelHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(v)
|
||||
}
|
||||
|
||||
func osGetenv(k string) string { return os.Getenv(k) }
|
||||
112
internal/update/semver.go
Normal file
112
internal/update/semver.go
Normal file
@ -0,0 +1,112 @@
|
||||
package update
|
||||
|
||||
// 版本号工具:**子系统内唯一一份**语义版本实现。
|
||||
//
|
||||
// ## 为什么必须收敛到一份(合并前的教训)
|
||||
//
|
||||
// 合并前有 6 个函数散在两个包里做同一件事:
|
||||
//
|
||||
// internal/kernelupgrade: ParseSemver / CmpSemver / versionNewer / trimTagV
|
||||
// internal/pluginupdate: trimV / isNewer / semverNums
|
||||
//
|
||||
// 三份实现的差异是真实的 bug 温床:
|
||||
// · kernel 侧只挡 len<2 ⇒ "1.4" 被当成 1.4.0(已修)
|
||||
// · plugin 侧用 map+正则另写一套,行为与 kernel 侧不一致
|
||||
// · trimV / trimTagV 名字不同、逻辑相同
|
||||
//
|
||||
// 版本比较错一次的后果很具体:把"已是最新"说成"有新版"(白编一场),
|
||||
// 或把"有新版本"说成"无需升级"(用户永远拿不到修复)。
|
||||
//
|
||||
// ⇒ 现在只导出一组:ParseSemver / CmpSemver / Newer / TrimTag。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Semver 是解析后的三段式版本号。
|
||||
type Semver struct{ Major, Minor, Patch int }
|
||||
|
||||
var pureSemverRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)$`)
|
||||
|
||||
// ParseSemver 解析纯 X.Y.Z(可带 v 前缀)。
|
||||
//
|
||||
// 返回 nil 表示"不是可比的三段式版本"——调用方应据此降级
|
||||
// (如退回字符串比较、或明确告知无法比较),而不是猜一个值。
|
||||
//
|
||||
// ★ 必须恰好三段。早期实现只挡 len<2,于是 "1.4" 漏过去、
|
||||
// 缺失的 patch 被零值填成 1.4.0 ⇒ "1.4 → 1.4.0" 被判成完全相同,
|
||||
// 进而说"无需升级",而两者内容未必相同。
|
||||
func ParseSemver(s string) *Semver {
|
||||
m := pureSemverRe.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
var v Semver
|
||||
if _, err := fmt.Sscanf(m[1]+"."+m[2]+"."+m[3], "%d.%d.%d", &v.Major, &v.Minor, &v.Patch); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// CmpSemver 比较两个已解析版本:<0 / 0 / >0。
|
||||
//
|
||||
// 任一侧为 nil 时返回 0(不可比)——调用方必须先判 nil,
|
||||
// 因为"无法比较"与"相等"是两回事,混同会让升级判定失效。
|
||||
func CmpSemver(a, b *Semver) int {
|
||||
if a == nil || b == nil {
|
||||
return 0
|
||||
}
|
||||
switch {
|
||||
case a.Major != b.Major:
|
||||
return a.Major - b.Major
|
||||
case a.Minor != b.Minor:
|
||||
return a.Minor - b.Minor
|
||||
default:
|
||||
return a.Patch - b.Patch
|
||||
}
|
||||
}
|
||||
|
||||
// Newer 判断版本串 a 是否严格新于 b。
|
||||
//
|
||||
// 语义要点:
|
||||
// - 只认纯三段式;任一侧不可解析 ⇒ false(不猜)
|
||||
// - "1.4.0" 与 "1.4.0dev" 视为**相同**(TrimTag 去 dev 后比)
|
||||
// —— 开发分支通常领先于同名正式版,不误报"有新版"
|
||||
// - 空串 b 表示"本地无版本信息",此时 a 非空即算新
|
||||
func Newer(a, b string) bool {
|
||||
if b == "" {
|
||||
return a != ""
|
||||
}
|
||||
pa, pb := ParseSemver(TrimTag(a)), ParseSemver(TrimTag(b))
|
||||
if pa == nil || pb == nil {
|
||||
return false
|
||||
}
|
||||
return CmpSemver(pa, pb) > 0
|
||||
}
|
||||
|
||||
// TrimTag 把版本串归一成可比形态:去 v 前缀、去 dev/预发布后缀。
|
||||
//
|
||||
// 处理 "v1.4.4" → "1.4.4"、"1.4.0dev" → "1.4.0"。
|
||||
// 非纯三段式(如 "1.4.4-rc1"、"release1")原样返回,
|
||||
// 由 ParseSemver 判为不可比 —— 这里不做有损猜测。
|
||||
func TrimTag(s string) string {
|
||||
out := s
|
||||
if len(out) > 0 && (out[0] == 'v' || out[0] == 'V') {
|
||||
out = out[1:]
|
||||
}
|
||||
// 去 dev 及其后(1.4.0dev / 1.4.0dev.1)
|
||||
if i := indexOf(out, "dev"); i >= 0 {
|
||||
out = out[:i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
Reference in New Issue
Block a user