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:
JianFeeeee
2026-10-25 20:30:39 +08:00
parent c9a32a98f0
commit a0813b5578
7 changed files with 909 additions and 4 deletions

View File

@ -88,14 +88,29 @@ func (r *ConfigRegistry) initCoreTable() {
r.db.Exec(`CREATE TABLE IF NOT EXISTS disabled_plugins (
name TEXT PRIMARY KEY,
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 {
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()
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
}
@ -131,12 +146,17 @@ type DisabledPluginInfo struct {
Name string `json:"name"`
DisabledAt string `json:"disabled_at"`
DisabledBy string `json:"disabled_by"`
// Reason 是禁用原因(可空)。
//
// 人工禁用通常没有原因;**内核自动禁用**必须填(如版本不兼容),
// 否则用户看到"已禁用"却不知为何,只能翻日志。
Reason string `json:"reason,omitempty"`
}
func (r *ConfigRegistry) ListDisabledPlugins() ([]DisabledPluginInfo, error) {
r.mu.RLock()
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 {
return nil, err
}
@ -144,7 +164,7 @@ func (r *ConfigRegistry) ListDisabledPlugins() ([]DisabledPluginInfo, error) {
var list []DisabledPluginInfo
for rows.Next() {
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
}
list = append(list, info)

View 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
}

View File

@ -801,6 +801,63 @@ func pluginEntryHash(plgDir string) string {
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 {
if r.isDisabled(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)
if err := plg.Start(plgSDK); err != nil {
log.Printf("[plugin] start %s: %v", name, err)
r.maybeAutoDisableIncompatible(name, err)
return false
}

View File

@ -83,6 +83,11 @@ type DisabledPluginInfo struct {
Name string `json:"name"`
DisabledAt string `json:"disabled_at"`
DisabledBy string `json:"disabled_by"`
// Reason 是禁用原因(可空)。
//
// 人工禁用通常为空;**内核自动禁用**必须填(如与内核版本不兼容),
// 让用户在界面上直接看到"为什么没了",而不是去翻日志。
Reason string `json:"reason,omitempty"`
}
// PluginMeta is the display-name metadata for a plugin (from plg.json / RegisterPluginMeta).