Files
HomeAgent/cmd/homed-upgrade/main_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

107 lines
3.8 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 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
}