mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 01:07:35 +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:
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
|
||||
509
internal/update/kernel.go
Normal file
509
internal/update/kernel.go
Normal file
@ -0,0 +1,509 @@
|
||||
// Package kernelupgrade 实现**内核**的升级闭环:探测 / 校验 / 备份 / 原子替换 / 重启 / 回滚。
|
||||
//
|
||||
// 抽成独立包而不是留在 cmd/homed-upgrade:内核侧的 kernel_update 工具
|
||||
// 与 CLI 必须走**同一套**逻辑。两份实现必然漂移,而这条路径上漂移的
|
||||
// 后果是"CLI 能升级、agent 调用却把机器搞坏"。
|
||||
//
|
||||
// ## 范围:只面向内核
|
||||
//
|
||||
// 只处理 homed / depgraph.bin / waiter。**不碰插件** —— 插件有独立通道
|
||||
// (plugin_update),且与内核的兼容性由"自动禁用不兼容插件"兜底。
|
||||
package update
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// binVersion 是 homed --version 的输出。
|
||||
type BinVersion struct {
|
||||
Version string
|
||||
Commit string
|
||||
BuildTime string
|
||||
SDKVersion string
|
||||
}
|
||||
|
||||
// readBinVersion 跑 `<bin> --version` 并解析。
|
||||
//
|
||||
// ★ 为什么用"跑它自己"而不是读文件里的字符串:
|
||||
//
|
||||
// 版本是 ldflags 注入的,从 ELF 里 grep 只能撞运气;而 --version 是内核
|
||||
// 自己算出来的,还能一并拿到 commit("同版本号不同 commit"很难察觉)。
|
||||
func ReadBinVersion(bin string) (*BinVersion, error) {
|
||||
if _, err := os.Stat(bin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := exec.Command(bin, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
// ★ 回退:老版本内核没有 --version(它是 2026-10-25 才加的)。
|
||||
//
|
||||
// 这正是 OTA 的**第一次**升级必然撞到的情况:新版工具要读线上版本,
|
||||
// 而线上那个版本还不支持被读。此时不能失败退出——否则所有人第一次
|
||||
// 升级都要人工介入。改为从运行中进程的环境/文件里找线索,找不到就
|
||||
// 诚实地说"未知",让版本比较降级(其余预检照常)。
|
||||
if v := InferVersionFromRunning(); v != nil {
|
||||
return v, nil
|
||||
}
|
||||
return nil, fmt.Errorf("执行 --version: %v(老版本内核不支持 --version,且无法从运行进程推断)", err)
|
||||
}
|
||||
v := &BinVersion{}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
k, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch k {
|
||||
case "version":
|
||||
v.Version = val
|
||||
case "commit":
|
||||
v.Commit = val
|
||||
case "build_time":
|
||||
v.BuildTime = val
|
||||
case "sdk":
|
||||
v.SDKVersion = val
|
||||
}
|
||||
}
|
||||
if v.Version == "" {
|
||||
return nil, fmt.Errorf("--version 输出里没有 version= 行: %q", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// compareVersions 给出可读结论。返回文案而不是 bool:
|
||||
// "能升但不能降"这类判断需要说明理由,用户才敢按 -apply。
|
||||
func CompareVersions(cur, nv *BinVersion) string {
|
||||
if cur == nil {
|
||||
return "无法读取当前版本,跳过比较(其余预检照常)"
|
||||
}
|
||||
c, n := ParseSemver(cur.Version), ParseSemver(nv.Version)
|
||||
switch {
|
||||
case n == nil || c == nil:
|
||||
// 版本号形态不标准(如 1.4.0dev):退化成字符串比较并说明
|
||||
if cur.Version == nv.Version && cur.Commit == nv.Commit {
|
||||
return "⚠️ 版本与 commit 都相同 —— 升级后内容不会变化,请确认包是否拿错"
|
||||
}
|
||||
return "版本号非标准形态,按字符串比较;实测内容有差异(commit 不同)"
|
||||
case CmpSemver(n, c) > 0:
|
||||
return "✅ 可升级:包内版本高于当前"
|
||||
case CmpSemver(n, c) == 0:
|
||||
if cur.Commit != nv.Commit {
|
||||
return fmt.Sprintf("⚠️ 同为 %s 但 commit 不同(%s → %s)—— 这是一次**同版本重发**,请确认是否有意为之",
|
||||
nv.Version, ShortCommit(cur.Commit), ShortCommit(nv.Commit))
|
||||
}
|
||||
return "⚠️ 与当前完全相同 —— 无需升级"
|
||||
default:
|
||||
return "❌ 这是**降级**(包内版本低于当前)。默认不建议;确认要回退请加 -allow-downgrade"
|
||||
}
|
||||
}
|
||||
|
||||
// Semver / ParseSemver / CmpSemver 已收敛到 semver.go(子系统内唯一一份)。
|
||||
// 合并前这里有一份私有实现,与 plugin 侧另写的一份行为不一致 —— 见 semver.go 头注释。
|
||||
|
||||
// pluginCompat 是一个与新内核 SDK 主版本不匹配的插件。
|
||||
type PluginCompat struct {
|
||||
Name string
|
||||
SDKVersion string
|
||||
WantMajor string
|
||||
}
|
||||
|
||||
// probePluginCompat 扫插件目录,读每个插件编译时的 SDK 版本。
|
||||
//
|
||||
// 数据来源:plugin.json 里**没有** sdk_version 字段,所以读不到就诚实地说
|
||||
// "无法预判",而不是瞎猜。能读到的(有 meta 文件的)才比较。
|
||||
func ProbePluginCompat(dir, wantSDK string) []PluginCompat {
|
||||
if dir == "" || wantSDK == "" {
|
||||
return nil
|
||||
}
|
||||
wantMajor := MajorOf(wantSDK)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []PluginCompat
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
pj := filepath.Join(dir, e.Name(), "plugin.json")
|
||||
b, err := os.ReadFile(pj)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var m struct {
|
||||
SDKVersion string `json:"sdk_version"`
|
||||
}
|
||||
json.Unmarshal(b, &m)
|
||||
if m.SDKVersion == "" {
|
||||
continue // plugin.json 不含该字段:无法预判,不虚报
|
||||
}
|
||||
if MajorOf(m.SDKVersion) != wantMajor {
|
||||
out = append(out, PluginCompat{Name: e.Name(), SDKVersion: m.SDKVersion, WantMajor: wantMajor})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
func MajorOf(v string) string {
|
||||
if i := strings.IndexByte(v, '.'); i > 0 {
|
||||
return v[:i]
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func ShortCommit(c string) string {
|
||||
if c == "" || c == "unknown" {
|
||||
return "unknown"
|
||||
}
|
||||
if len(c) > 8 {
|
||||
return c[:8]
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// extractPkg 解包并返回包含 bin/ 的根目录。
|
||||
func ExtractPkg(pkg, dst string) (string, error) {
|
||||
f, err := os.Open(pkg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("非 gzip: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 防路径穿越(包是被信任的,但这条成本几乎为零)
|
||||
clean := filepath.Clean(h.Name)
|
||||
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
||||
return "", fmt.Errorf("包内有非法路径: %s", h.Name)
|
||||
}
|
||||
p := filepath.Join(dst, clean)
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
os.MkdirAll(p, 0755)
|
||||
case tar.TypeReg:
|
||||
os.MkdirAll(filepath.Dir(p), 0755)
|
||||
out, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(out, tr); err != nil {
|
||||
out.Close()
|
||||
return "", err
|
||||
}
|
||||
out.Close()
|
||||
}
|
||||
}
|
||||
// 找含 bin/homed 的那层
|
||||
if FindFile(dst, "homed") == "" {
|
||||
return "", fmt.Errorf("包内无 homed")
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// findFile 在 root 下递归找文件名匹配的第一个。
|
||||
func FindFile(root, name string) string {
|
||||
var found string
|
||||
filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || found != "" {
|
||||
return nil
|
||||
}
|
||||
if info.Name() == name {
|
||||
found = p
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func FileSize(p string) int64 {
|
||||
if st, err := os.Stat(p); err == nil {
|
||||
return st.Size()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// doUpgrade 执行真实升级:备份 → 预检 → 原子替换 → 重启 → 后置验证 → 失败回滚。
|
||||
//
|
||||
// 每一步失败都往回退,且**回滚后必须能正常启动**——这是本函数的核心承诺:
|
||||
// 宁可升级失败,不可让机器起不来。
|
||||
func DoUpgrade(root, binPath, newDG, pluginsDir string) error {
|
||||
newBin := FindFile(root, "homed")
|
||||
if newBin == "" {
|
||||
return fmt.Errorf("包内无 homed")
|
||||
}
|
||||
|
||||
// ── 预检:新二进制必须能自报版本(说明它是可执行的、架构对的)──
|
||||
nv, err := ReadBinVersion(newBin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("预检失败,新二进制不可执行: %w", err)
|
||||
}
|
||||
fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version)
|
||||
|
||||
// ── 备份:只备本次会动的文件(不整目录拷贝,那是几百 MB)──
|
||||
ts := NowStamp()
|
||||
backup := filepath.Join("/root", "homed-upgrade-backup-"+ts)
|
||||
if err := os.MkdirAll(backup, 0755); err != nil {
|
||||
return fmt.Errorf("建备份目录: %w", err)
|
||||
}
|
||||
targets := []string{binPath}
|
||||
if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); FileExists(dg) {
|
||||
targets = append(targets, dg)
|
||||
}
|
||||
for _, t := range targets {
|
||||
if err := CopyFile(t, filepath.Join(backup, filepath.Base(t))); err != nil {
|
||||
return fmt.Errorf("备份 %s: %w", t, err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("已备份 %d 个文件 → %s\n", len(targets), backup)
|
||||
|
||||
rollback := func(cause error) error {
|
||||
fmt.Printf("\n❌ %v\n== 开始回滚 ==\n", cause)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
// ── 替换(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))
|
||||
}
|
||||
fmt.Println("已替换 depgraph.bin")
|
||||
}
|
||||
if err := CopyFile(newBin, binPath); err != nil {
|
||||
return rollback(fmt.Errorf("替换 homed: %w", err))
|
||||
}
|
||||
fmt.Println("已替换 homed")
|
||||
|
||||
// ── 重启 + 后置验证 ──
|
||||
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))
|
||||
}
|
||||
fmt.Printf("\n✅ 升级完成:%s (commit %s)\n", got.Version, ShortCommit(got.Commit))
|
||||
fmt.Printf(" 备份保留在 %s(确认稳定后可删)\n", backup)
|
||||
return 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
|
||||
}
|
||||
|
||||
func CopyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
st, err := in.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := dst + ".tmp-upgrade"
|
||||
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, st.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
// 原子替换:先写临时文件再 rename,避免"写到一半进程在跑"
|
||||
return os.Rename(tmp, dst)
|
||||
}
|
||||
|
||||
func FileExists(p string) bool {
|
||||
_, err := os.Stat(p)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func NowStamp() string {
|
||||
return time.Now().Format("20060102-150405")
|
||||
}
|
||||
|
||||
// sleepSecond 隔一秒(抽出来便于将来换成可注入的等待,测试不必真睡)。
|
||||
func sleepSecond() { time.Sleep(time.Second) }
|
||||
|
||||
// inferVersionFromRunning 在老内核不支持 --version 时,尽力推断当前版本。
|
||||
//
|
||||
// 手段按可靠性排序,任一成功即返回:
|
||||
// 1. 读 /proc/<pid>/exe 指向的文件,用 strings 找 "version=" 形态(不保证有)
|
||||
// 2. 读 WebUI 的 /api/v1/agents(那里返回 meta.Version)—— 需服务在跑
|
||||
//
|
||||
// 都失败则返回 nil,调用方把比较降级为"未知",**不阻断**升级。
|
||||
func InferVersionFromRunning() *BinVersion {
|
||||
// 手段 1:从二进制里找注入的版本串。
|
||||
//
|
||||
// ldflags 注入的字符串在 rodata 里是裸的,形如 "1.4.0dev";
|
||||
// 配合 "commit=" 一起找才有意义,所以这里只认成对出现的情况。
|
||||
if v := scanBinaryForVersion("/usr/local/bin/homed"); v != nil {
|
||||
return v
|
||||
}
|
||||
// 手段 2:问 waiter。
|
||||
//
|
||||
// ★ 这条最可靠:waiter 与 homed **同批构建**(同一个版本、同一个 commit),
|
||||
// 而且它一直支持 --version(输出形如
|
||||
// `waiter 1.4.0 (commit 98850e1, built 2026-10-17T09:20:25Z)`)。
|
||||
// 老内核没有 --version,但同批的 waiter 有 —— 用它反推。
|
||||
if v := versionFromWaiter("/usr/local/bin/waiter"); v != nil {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// versionFromWaiter 解析 waiter 的版本行反推内核版本。
|
||||
//
|
||||
// 只认 "waiter X.Y.Z (commit ABC, built ...)" 这一种形态;
|
||||
// 格式变了就返回 nil(宁可说"未知",不可瞎猜)。
|
||||
func versionFromWaiter(path string) *BinVersion {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil
|
||||
}
|
||||
out, err := exec.Command(path, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
line := strings.TrimSpace(string(out))
|
||||
// 取第一个语义化版本号
|
||||
v := FirstSemverLike(line)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
commit := ""
|
||||
if i := strings.Index(line, "commit "); i >= 0 {
|
||||
rest := line[i+len("commit "):]
|
||||
if j := strings.IndexAny(rest, ",)"); j > 0 {
|
||||
commit = strings.TrimSpace(rest[:j])
|
||||
}
|
||||
}
|
||||
return &BinVersion{Version: v, Commit: commit}
|
||||
}
|
||||
|
||||
// scanBinaryForVersion 在二进制里搜版本串(老内核的兜底手段)。
|
||||
func scanBinaryForVersion(path string) *BinVersion {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
// 只读前 200MB:版本串在 rodata,通常在文件前 1/3
|
||||
buf := make([]byte, 200<<20)
|
||||
n, _ := io.ReadFull(f, buf)
|
||||
buf = buf[:n]
|
||||
|
||||
// 找 SDKCompatibleVersion 附近的三元组:本仓 meta 变量相邻存放,
|
||||
// 命中 "1.3.0" 且附近有语义化版本形态时,取最近的一个当内核版本。
|
||||
// ★ 这是**尽力而为**:可能撞到别的版本号,所以只作为"未知"的替代,
|
||||
// 且返回的 commit 恒为 unknown(无法可靠恢复)。
|
||||
idx := strings.Index(string(buf), "SDKCompatibleVersion")
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
// 在附近 4KB 里找 X.Y.Z 形态
|
||||
window := string(buf[maxInt(0, idx-4096):minInt(len(buf), idx+4096)])
|
||||
if v := FirstSemverLike(window); v != "" {
|
||||
return &BinVersion{Version: v, Commit: "unknown"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FirstSemverLike(s string) string {
|
||||
for i := 0; i+5 <= len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
continue
|
||||
}
|
||||
j := i
|
||||
dots := 0
|
||||
for j < len(s) && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.') {
|
||||
if s[j] == '.' {
|
||||
dots++
|
||||
}
|
||||
j++
|
||||
}
|
||||
if dots == 2 && j-i >= 5 {
|
||||
return s[i:j]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
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