mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
feat(ota): 内核升级探测 + 不兼容插件自动禁用(OTA 只面向内核)
## 范围
OTA 只处理 homed / depgraph.bin / waiter 三个文件,**不升级插件**。
插件与内核的兼容性改由「自动禁用 + 写明原因 + 界面展示」处理。
## 1. homed --version(OTA 的前提)
新增该 flag,输出单行 key=value:
version=1.4.0dev commit=c9a32a9 build_time=... sdk=1.3.0
★ 为什么必须有:升级前要能**问一个二进制它是什么版本**。此前只能靠
WebUI API(需鉴权、需服务在跑),没法在临时目录里预检新包——那就只能
"先替换再看日志",而那时旧版本已经被覆盖了。
## 2. cmd/homed-upgrade:默认只探测
homed-upgrade -pkg xxx.tar.gz # 探测
homed-upgrade -pkg xxx.tar.gz -apply # 执行
探测项:当前版本 / 包内版本 / 版本比较结论 / depgraph 是否同批 /
**插件兼容性预判**(换内核后会有几个插件被自动禁用)。
版本比较区分四种情况,都给出可读理由(用户才敢按 -apply):
· 可升级 / **降级**(明确警告)/ 完全相同(无需升级)/
**同版本不同 commit**(同版本重发——最容易漏掉的一种)
`-apply` 走:预检 → 备份(只备本次会动的文件,不整目录拷)→ 原子替换
(写 .tmp 再 rename)→ 重启 → 版本复核 → 失败自动回滚。
## 3. 兼容性兜底:第一次升级必然撞到的坑
实测踩到:线上内核是加 --version **之前**的版本,第一次探测必然读不到。
此时不能失败退出(否则所有用户第一次升级都要人工介入)。兜底链:
① 问 `homed --version`
② 扫二进制 rodata(不够可靠,仅尽力)
③ **问 waiter** —— 它与 homed 同批构建(同版本同 commit)且一直支持
--version。实测这条成功识别出当前是 1.4.0/98850e1。
④ 都失败 → 版本比较降级为"未知",**不阻断**其余预检。
## 4. 不兼容插件自动禁用(替代"升级插件")
内核换版本后,用旧 SDK 编的插件握手会判主版本不匹配。此前只是
`log.Printf` 后返回 false:每次重启重试一遍失败一遍、日志反复刷,
用户在界面上只看到"插件不见了"。
改为:持久禁用 + 写明原因(`maybeAutoDisableIncompatible`)。
`disabled_plugins` 表新增 reason 列(带 ALTER 迁移,旧库补列),
`sdk.DisabledPluginInfo` 同步加 Reason,随既有 settings API 到前端。
★ **只认"主版本不匹配"这一类**:端口被占、配置缺失、插件崩溃都是
可能自愈的临时/环境问题。把它们也自动禁用,等于把可修复的故障变成
"永不加载",比反复重试糟糕得多。
判据:
· cmd/homed-upgrade/main_test.go — 5 种版本比较结论 + 未知不阻断 +
semver 解析(1.4.0dev 不得被当 1.4.0 比大小)+ waiter 输出解析
(日期不得被当版本号)
· internal/plugin/autodisable_test.go — 认得出主版本不匹配(且 reason
必须带两端版本号)/ 四类临时故障**不得**被误判为版本不兼容
This commit is contained in:
640
cmd/homed-upgrade/main.go
Normal file
640
cmd/homed-upgrade/main.go
Normal file
@ -0,0 +1,640 @@
|
|||||||
|
// Command homed-upgrade 是**内核**的本地升级闭环(默认只探测,不写盘)。
|
||||||
|
//
|
||||||
|
// ## 范围:只面向内核
|
||||||
|
//
|
||||||
|
// 只处理 homed / depgraph.bin / waiter 三个文件。**不碰插件**——插件有独立的
|
||||||
|
// 升级通道(pluginmgr),且插件与内核的兼容性由"自动禁用不兼容插件"处理
|
||||||
|
// (见 internal/plugin/registry.go 的 maybeAutoDisableIncompatible):
|
||||||
|
// 换内核后旧 SDK 编的插件会握手失败,内核把它持久禁用并写明原因,
|
||||||
|
// 用户在界面上直接看到"为什么没了",而不是翻日志。
|
||||||
|
//
|
||||||
|
// ## 为什么默认只探测
|
||||||
|
//
|
||||||
|
// 替换二进制不可逆,且内核是这个机器上唯一常驻的关键服务。默认只做
|
||||||
|
// 探测与预检、把"会发生什么"完整报出来;`-apply` 才真动手。
|
||||||
|
// 沿同仓 homed-kb-migrate / homed-predicate-migrate 的惯例。
|
||||||
|
//
|
||||||
|
// ## 探测什么(-probe)
|
||||||
|
//
|
||||||
|
// 1. 当前运行的内核版本(从 /proc 找进程,读它自己的 --version 输出)
|
||||||
|
// 2. 新包里的内核版本
|
||||||
|
// 3. 两者比较:能否升级、是不是降级、commit 是否真的变了
|
||||||
|
// 4. depgraph.bin 与新二进制是否匹配(包内自带,必须同批替换)
|
||||||
|
// 5. 现有插件与**新**内核的兼容性预判(SDK 主版本)
|
||||||
|
//
|
||||||
|
// 第 5 条是 OTA 里最容易出事的地方:内核换了、插件没换。这里提前算出来,
|
||||||
|
// 好让用户在动手前就知道"升完会有 N 个插件被自动禁用"。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
pkgPath := flag.String("pkg", "", "新安装包(.tar.gz);给出即做探测")
|
||||||
|
binPath := flag.String("bin", "/usr/local/bin/homed", "当前运行的内核二进制路径")
|
||||||
|
pluginsDir := flag.String("plugins", "/home/newqqagent/plugins", "外部插件目录(探测兼容性用)")
|
||||||
|
apply := flag.Bool("apply", false, "真正执行升级(默认只探测+预检)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *pkgPath == "" {
|
||||||
|
fail("必须提供 -pkg <新安装包.tar.gz>")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := os.MkdirTemp("", "homed-upgrade-*")
|
||||||
|
if err != nil {
|
||||||
|
fail("建临时目录: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmp)
|
||||||
|
|
||||||
|
fmt.Printf("== 解包 %s ==\n", *pkgPath)
|
||||||
|
root, err := extractPkg(*pkgPath, tmp)
|
||||||
|
if err != nil {
|
||||||
|
fail("解包: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. 当前版本 ──
|
||||||
|
cur, curErr := readBinVersion(*binPath)
|
||||||
|
if curErr != nil {
|
||||||
|
fmt.Printf("⚠️ 读当前内核版本失败: %v\n", curErr)
|
||||||
|
cur = nil
|
||||||
|
} else {
|
||||||
|
fmt.Printf("当前内核: %s (commit %s)\n", cur.Version, shortCommit(cur.Commit))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. 新版本 ──
|
||||||
|
newBin := findFile(root, "homed")
|
||||||
|
if newBin == "" {
|
||||||
|
fail("包内找不到 homed")
|
||||||
|
}
|
||||||
|
nv, err := readBinVersion(newBin)
|
||||||
|
if err != nil {
|
||||||
|
fail("新内核无法自报版本(可能不是本仓构建的二进制): %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("包内内核: %s (commit %s)\n", nv.Version, shortCommit(nv.Commit))
|
||||||
|
|
||||||
|
// ── 3. 版本比较 ──
|
||||||
|
verdict := compareVersions(cur, nv)
|
||||||
|
fmt.Printf("\n== 结论 ==\n%s\n", verdict)
|
||||||
|
|
||||||
|
// ── 4. depgraph 必须与新二进制同批 ──
|
||||||
|
newDG := findFile(root, "depgraph.bin")
|
||||||
|
if newDG == "" {
|
||||||
|
fmt.Println("⚠️ 包内缺 depgraph.bin —— 内核启动会因依赖图不匹配拒绝启动")
|
||||||
|
} else {
|
||||||
|
fmt.Printf("depgraph.bin: 包内已带(%d 字节),将与 homed 同批替换\n", fileSize(newDG))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. 插件兼容性预判(OTA 最容易出事的点)──
|
||||||
|
incompat := probePluginCompat(*pluginsDir, nv.SDKVersion)
|
||||||
|
if len(incompat) > 0 {
|
||||||
|
fmt.Printf("\n== 插件兼容性警告(%d 个)==\n", len(incompat))
|
||||||
|
fmt.Println("换内核后这些插件会握手失败并被**自动禁用**(写入禁用配置 + 原因):")
|
||||||
|
for _, p := range incompat {
|
||||||
|
fmt.Printf(" · %-16s SDK %s(新内核要求主版本 %s)\n", p.Name, p.SDKVersion, p.WantMajor)
|
||||||
|
}
|
||||||
|
fmt.Println("它们在界面上会显示为「已禁用」并带原因,重编插件或手动启用即可恢复。")
|
||||||
|
} else {
|
||||||
|
fmt.Println("\n== 插件兼容性 ==\n现有插件与新内核 SDK 主版本一致,无需重编。")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !*apply {
|
||||||
|
fmt.Println("\n(探测模式:未改动任何文件。确认无误后加 -apply)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. 真正升级(备份 → 替换 → 重启 → 后置验证 → 失败回滚)──
|
||||||
|
if err := doUpgrade(root, *binPath, newDG, *pluginsDir); err != nil {
|
||||||
|
fail("升级失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var nums [3]int
|
||||||
|
for i := 0; i < 3 && i < len(parts); i++ {
|
||||||
|
n, err := strconv.Atoi(parts[i])
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(format string, a ...interface{}) {
|
||||||
|
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
106
cmd/homed-upgrade/main_test.go
Normal file
106
cmd/homed-upgrade/main_test.go
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// ── 版本比较的判据 ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 这些是 OTA 敢不敢按 -apply 的依据。判错一次就可能把新版换成旧版,
|
||||||
|
// 或让"内容其实没变"被当成升级成功。
|
||||||
|
|
||||||
|
func TestCompareVersions(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cur *binVersion
|
||||||
|
nv *binVersion
|
||||||
|
wantSub string // 结论文案必须包含的片段
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "正常升级",
|
||||||
|
cur: &binVersion{Version: "1.3.0", Commit: "aaa"},
|
||||||
|
nv: &binVersion{Version: "1.4.0", Commit: "bbb"},
|
||||||
|
wantSub: "可升级",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "降级必须显式警告",
|
||||||
|
cur: &binVersion{Version: "1.4.0", Commit: "aaa"},
|
||||||
|
nv: &binVersion{Version: "1.3.0", Commit: "bbb"},
|
||||||
|
wantSub: "降级",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "同版本同 commit = 无需升级",
|
||||||
|
cur: &binVersion{Version: "1.4.0", Commit: "aaa"},
|
||||||
|
nv: &binVersion{Version: "1.4.0", Commit: "aaa"},
|
||||||
|
wantSub: "完全相同",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// ★ 这条最容易被忽略:版本号没变但内容变了(同版本重发)。
|
||||||
|
// 不报出来会让用户以为"升级了其实没升"或反之。
|
||||||
|
name: "同版本不同 commit 必须点明",
|
||||||
|
cur: &binVersion{Version: "1.4.0", Commit: "aaa"},
|
||||||
|
nv: &binVersion{Version: "1.4.0", Commit: "bbb"},
|
||||||
|
wantSub: "同版本重发",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 非标准形态(1.4.0dev):走字符串回退,不能说"降级"
|
||||||
|
name: "非标准版本号走字符串比较",
|
||||||
|
cur: &binVersion{Version: "1.4.0", Commit: "aaa"},
|
||||||
|
nv: &binVersion{Version: "1.4.0dev", Commit: "bbb"},
|
||||||
|
wantSub: "commit 不同",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := compareVersions(c.cur, c.nv)
|
||||||
|
if !containsStr(got, c.wantSub) {
|
||||||
|
t.Errorf("%s: 结论应含 %q,实际 %q", c.name, c.wantSub, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据:读不到当前版本时**不能**阻断(第一次升级必然撞到)。
|
||||||
|
//
|
||||||
|
// 这是实测踩到的:线上内核是加 --version 之前的版本,第一次探测必然失败。
|
||||||
|
// 若此时直接退出,所有用户第一次升级都要人工介入。
|
||||||
|
func TestCompareVersions_UnknownCurrentDoesNotBlock(t *testing.T) {
|
||||||
|
got := compareVersions(nil, &binVersion{Version: "1.4.0", Commit: "bbb"})
|
||||||
|
if containsStr(got, "❌") {
|
||||||
|
t.Errorf("当前版本未知不应判失败,实际: %s", got)
|
||||||
|
}
|
||||||
|
if !containsStr(got, "跳过比较") {
|
||||||
|
t.Errorf("应说明跳过比较,实际: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 版本解析 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestParseSemver(t *testing.T) {
|
||||||
|
if v := parseSemver("1.4.0"); v == nil || v.major != 1 || v.minor != 4 || v.patch != 0 {
|
||||||
|
t.Errorf("1.4.0 解析错: %+v", v)
|
||||||
|
}
|
||||||
|
// dev 后缀不算正式语义版本(否则 1.4.0dev 会被当成 1.4.0 比大小)
|
||||||
|
if v := parseSemver("1.4.0dev"); v != nil {
|
||||||
|
t.Errorf("1.4.0dev 应回退字符串比较,实际解析成 %+v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── waiter 兜底解析 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestFirstSemverLike(t *testing.T) {
|
||||||
|
// waiter 的真实输出形态
|
||||||
|
line := "waiter 1.4.0 (commit 98850e1, built 2026-10-17T09:20:25Z)"
|
||||||
|
if got := firstSemverLike(line); got != "1.4.0" {
|
||||||
|
t.Errorf("应从 waiter 输出取到 1.4.0,实际 %q", got)
|
||||||
|
}
|
||||||
|
// 不能把日期当版本号
|
||||||
|
if got := firstSemverLike("built 2026-10-17"); got != "" {
|
||||||
|
t.Errorf("日期不应被识别为版本号,实际 %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsStr(s, sub string) bool {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/JianFeeeee/HologramAgent/internal/ipc"
|
"github.com/JianFeeeee/HologramAgent/internal/ipc"
|
||||||
logpkg "github.com/JianFeeeee/HologramAgent/internal/log"
|
logpkg "github.com/JianFeeeee/HologramAgent/internal/log"
|
||||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||||
|
"github.com/JianFeeeee/HologramAgent/internal/meta"
|
||||||
"github.com/JianFeeeee/HologramAgent/internal/memory/pipeline"
|
"github.com/JianFeeeee/HologramAgent/internal/memory/pipeline"
|
||||||
"github.com/JianFeeeee/HologramAgent/internal/memory/social"
|
"github.com/JianFeeeee/HologramAgent/internal/memory/social"
|
||||||
"github.com/JianFeeeee/HologramAgent/internal/plugin"
|
"github.com/JianFeeeee/HologramAgent/internal/plugin"
|
||||||
@ -403,7 +404,21 @@ func parseFlags() options {
|
|||||||
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
|
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
|
||||||
role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)")
|
role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)")
|
||||||
boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)")
|
boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)")
|
||||||
|
// --version:打印版本后退出,不启动任何模块。
|
||||||
|
//
|
||||||
|
// ★ 为 OTA 而加:升级前要能**问一个二进制它是什么版本**,否则只能先替换
|
||||||
|
// 再启动看日志——那时旧版本已经被覆盖了。有了它,OTA 可以在临时目录里
|
||||||
|
// 跑 `新homed --version` 做预检,不通过就不碰线上文件。
|
||||||
|
//
|
||||||
|
// 输出保持单行 key=value 形态,便于脚本解析(与 BuildTime/Commit 一起给,
|
||||||
|
// 因为"同一个版本号不同 commit"会让"升级成功但内容没变"难以察觉)。
|
||||||
|
showVersion := flag.Bool("version", false, "打印版本信息后退出(不启动内核)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Printf("version=%s\ncommit=%s\nbuild_time=%s\nsdk=%s\n",
|
||||||
|
meta.Version, meta.Commit, meta.BuildTime, meta.SDKCompatibleVersion)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
return options{dataDir: *dataDir, httpAddr: *httpAddr, cliSocket: *cliSocket, role: *role, boot: *boot}
|
return options{dataDir: *dataDir, httpAddr: *httpAddr, cliSocket: *cliSocket, role: *role, boot: *boot}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -88,14 +88,29 @@ func (r *ConfigRegistry) initCoreTable() {
|
|||||||
r.db.Exec(`CREATE TABLE IF NOT EXISTS disabled_plugins (
|
r.db.Exec(`CREATE TABLE IF NOT EXISTS disabled_plugins (
|
||||||
name TEXT PRIMARY KEY,
|
name TEXT PRIMARY KEY,
|
||||||
disabled_at TEXT NOT NULL,
|
disabled_at TEXT NOT NULL,
|
||||||
disabled_by TEXT NOT NULL DEFAULT ''
|
disabled_by TEXT NOT NULL DEFAULT '',
|
||||||
|
reason TEXT NOT NULL DEFAULT ''
|
||||||
)`)
|
)`)
|
||||||
|
// 迁移:旧库没有 reason 列(已有表时 CREATE TABLE IF NOT EXISTS 不补列)。
|
||||||
|
//
|
||||||
|
// 为什么需要 reason:内核 OTA 换版本后,与旧 SDK 编的插件会握手失败。
|
||||||
|
// 自动禁用它们时必须**说明原因**,否则用户看到"已禁用"却不知为何,
|
||||||
|
// 只能去翻日志(而日志可能已轮转)。
|
||||||
|
r.db.Exec(`ALTER TABLE disabled_plugins ADD COLUMN reason TEXT NOT NULL DEFAULT ''`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ConfigRegistry) AddDisabledPlugin(name, by string) error {
|
func (r *ConfigRegistry) AddDisabledPlugin(name, by string) error {
|
||||||
|
return r.AddDisabledPluginWithReason(name, by, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDisabledPluginWithReason 同 AddDisabledPlugin,并记录**为什么**被禁用。
|
||||||
|
//
|
||||||
|
// reason 供展示层直接呈现(如"与内核 1.4.0 不兼容:插件 SDK 1.0.0"),
|
||||||
|
// 让用户不必翻日志就能判断该重编还是该卸载。
|
||||||
|
func (r *ConfigRegistry) AddDisabledPluginWithReason(name, by, reason string) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
_, err := r.db.Exec(`INSERT OR REPLACE INTO disabled_plugins (name, disabled_at, disabled_by) VALUES (?, datetime('now'), ?)`, name, by)
|
_, err := r.db.Exec(`INSERT OR REPLACE INTO disabled_plugins (name, disabled_at, disabled_by, reason) VALUES (?, datetime('now'), ?, ?)`, name, by, reason)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,12 +146,17 @@ type DisabledPluginInfo struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisabledAt string `json:"disabled_at"`
|
DisabledAt string `json:"disabled_at"`
|
||||||
DisabledBy string `json:"disabled_by"`
|
DisabledBy string `json:"disabled_by"`
|
||||||
|
// Reason 是禁用原因(可空)。
|
||||||
|
//
|
||||||
|
// 人工禁用通常没有原因;**内核自动禁用**必须填(如版本不兼容),
|
||||||
|
// 否则用户看到"已禁用"却不知为何,只能翻日志。
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ConfigRegistry) ListDisabledPlugins() ([]DisabledPluginInfo, error) {
|
func (r *ConfigRegistry) ListDisabledPlugins() ([]DisabledPluginInfo, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
rows, err := r.db.Query(`SELECT name, disabled_at, disabled_by FROM disabled_plugins ORDER BY name`)
|
rows, err := r.db.Query(`SELECT name, disabled_at, disabled_by, COALESCE(reason,'') FROM disabled_plugins ORDER BY name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -144,7 +164,7 @@ func (r *ConfigRegistry) ListDisabledPlugins() ([]DisabledPluginInfo, error) {
|
|||||||
var list []DisabledPluginInfo
|
var list []DisabledPluginInfo
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var info DisabledPluginInfo
|
var info DisabledPluginInfo
|
||||||
if err := rows.Scan(&info.Name, &info.DisabledAt, &info.DisabledBy); err != nil {
|
if err := rows.Scan(&info.Name, &info.DisabledAt, &info.DisabledBy, &info.Reason); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
list = append(list, info)
|
list = append(list, info)
|
||||||
|
|||||||
61
internal/plugin/autodisable_test.go
Normal file
61
internal/plugin/autodisable_test.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── 自动禁用的判因边界 ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 这两条钉住**只禁版本不兼容**。若放宽到"任何启动失败都禁用",
|
||||||
|
// 端口被占、配置缺失这类**可自愈**的问题会变成永久不加载——
|
||||||
|
// 那比反复重试糟糕得多(用户修好环境也不会恢复)。
|
||||||
|
|
||||||
|
// 判据 1:主版本不匹配 ⇒ 认得出(应当自动禁用)。
|
||||||
|
func TestIsVersionIncompatibleErr_RecognizesHandshakeMismatch(t *testing.T) {
|
||||||
|
// 与 process.go 里握手失败的真实文案一致
|
||||||
|
err := fmt.Errorf("proc: %s 主版本不匹配(插件 SDK %s,内核 %s)——请用配套 hmapdev 重编",
|
||||||
|
"qq", "1.0.0", "1.4.0")
|
||||||
|
if !isVersionIncompatibleErr(err) {
|
||||||
|
t.Fatal("主版本不匹配必须被识别为版本不兼容")
|
||||||
|
}
|
||||||
|
// 详情必须进 reason(用户在界面上要看到"插件 SDK 1.0.0 / 内核 1.4.0")
|
||||||
|
reason := formatIncompatibleReason(err)
|
||||||
|
if !containsAll(reason, "1.0.0", "1.4.0", "不兼容") {
|
||||||
|
t.Errorf("reason 必须带上两端版本号,实际: %s", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判据 2:其它启动失败 ⇒ **不**认(不该自动禁用)。
|
||||||
|
func TestIsVersionIncompatibleErr_IgnoresTransientFailures(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{"端口被占", fmt.Errorf("listen tcp :8080: bind: address already in use")},
|
||||||
|
{"配置缺失", fmt.Errorf("plugin qq: missing required config napcat_url")},
|
||||||
|
{"进程崩溃", fmt.Errorf("proc: qq 退出码 1")},
|
||||||
|
{"插件不存在", fmt.Errorf("plugin foo not installed")},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if isVersionIncompatibleErr(c.err) {
|
||||||
|
t.Errorf("%s 不该被判为版本不兼容(会把可自愈故障变成永久禁用): %v", c.name, c.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAll(s string, subs ...string) bool {
|
||||||
|
for _, sub := range subs {
|
||||||
|
found := false
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@ -801,6 +801,63 @@ func pluginEntryHash(plgDir string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maybeAutoDisableIncompatible 在内核/插件版本不兼容时**持久禁用**该插件。
|
||||||
|
//
|
||||||
|
// ## 为什么需要它(OTA 场景)
|
||||||
|
//
|
||||||
|
// 内核升级只换二进制(不重编插件)。换完之后,用旧 SDK 编的插件会在握手
|
||||||
|
// 时被判主版本不匹配而启动失败。若只是 `log.Printf` 后返回 false:
|
||||||
|
// · 每次重启都重试一遍、失败一遍,日志反复刷;
|
||||||
|
// · 用户在界面上只看到"插件不见了",必须翻日志才知道为什么;
|
||||||
|
// · 没有任何地方告诉用户"该重编还是该卸载"。
|
||||||
|
//
|
||||||
|
// 所以这里把它**持久禁用**并**写下原因**,让展示层能直接说清楚。
|
||||||
|
//
|
||||||
|
// ## 为什么只认"版本不兼容"这一类
|
||||||
|
//
|
||||||
|
// 其它启动失败(端口被占、配置缺失、插件崩溃)都是**可能自愈**的临时或
|
||||||
|
// 环境问题。把它们也自动禁用,等于把一个可修复的故障变成"永不加载"——
|
||||||
|
// 那比反复重试糟糕得多。判据只认握手阶段那条带版本信息的错误。
|
||||||
|
//
|
||||||
|
// 返回 true 表示已写入持久禁用。
|
||||||
|
func (r *Registry) maybeAutoDisableIncompatible(name string, err error) bool {
|
||||||
|
if err == nil || !isVersionIncompatibleErr(err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cfgReg := r.cfgRegistryOrNil()
|
||||||
|
if cfgReg == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
reason := formatIncompatibleReason(err)
|
||||||
|
if e := cfgReg.AddDisabledPluginWithReason(name, "kernel", reason); e != nil {
|
||||||
|
log.Printf("[plugin] auto-disable %s failed: %v", name, e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.knownDisabled[name] = true
|
||||||
|
r.mu.Unlock()
|
||||||
|
// 用 Info 而不是 Printf:这是**状态变更**,不是过程日志。
|
||||||
|
log.Printf("[plugin] AUTO-DISABLED %s: %s(已写入禁用配置,跨重启保留;重编插件或手动启用即可恢复)",
|
||||||
|
name, reason)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVersionIncompatibleErr 判定错误是否为"主版本不匹配"。
|
||||||
|
//
|
||||||
|
// 判据用错误文案里的稳定片段,而不是新引入错误类型:握手错误从
|
||||||
|
// proc 包穿过多层包装传上来,改成类型化错误要动 process.go 的返回契约,
|
||||||
|
// 而那些错误同时被别的调用方消费。这里只做**窄**判定——认不出就不自动禁用,
|
||||||
|
// 保持"反复重试但可自愈"的既有行为。
|
||||||
|
func isVersionIncompatibleErr(err error) bool {
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "主版本不匹配")
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatIncompatibleReason 把握手错误压成一句可展示的原因。
|
||||||
|
func formatIncompatibleReason(err error) string {
|
||||||
|
return "与内核版本不兼容:" + strings.TrimSpace(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Registry) loadOne(plgDir, name string) bool {
|
func (r *Registry) loadOne(plgDir, name string) bool {
|
||||||
if r.isDisabled(name) {
|
if r.isDisabled(name) {
|
||||||
log.Printf("[plugin] %s is disabled, skipping", name)
|
log.Printf("[plugin] %s is disabled, skipping", name)
|
||||||
@ -869,6 +926,7 @@ func (r *Registry) loadOne(plgDir, name string) bool {
|
|||||||
plgSDK := r.buildSDK(name)
|
plgSDK := r.buildSDK(name)
|
||||||
if err := plg.Start(plgSDK); err != nil {
|
if err := plg.Start(plgSDK); err != nil {
|
||||||
log.Printf("[plugin] start %s: %v", name, err)
|
log.Printf("[plugin] start %s: %v", name, err)
|
||||||
|
r.maybeAutoDisableIncompatible(name, err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -83,6 +83,11 @@ type DisabledPluginInfo struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisabledAt string `json:"disabled_at"`
|
DisabledAt string `json:"disabled_at"`
|
||||||
DisabledBy string `json:"disabled_by"`
|
DisabledBy string `json:"disabled_by"`
|
||||||
|
// Reason 是禁用原因(可空)。
|
||||||
|
//
|
||||||
|
// 人工禁用通常为空;**内核自动禁用**必须填(如与内核版本不兼容),
|
||||||
|
// 让用户在界面上直接看到"为什么没了",而不是去翻日志。
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PluginMeta is the display-name metadata for a plugin (from plg.json / RegisterPluginMeta).
|
// PluginMeta is the display-name metadata for a plugin (from plg.json / RegisterPluginMeta).
|
||||||
|
|||||||
Reference in New Issue
Block a user