Files
HomeAgent/internal/plugin/autodisable_test.go
JianFeeeee a0813b5578 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
  必须带两端版本号)/ 四类临时故障**不得**被误判为版本不兼容
2026-10-25 20:30:39 +08:00

62 lines
2.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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