mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
## 为什么必须是第 0 层(用户口径) update 肩负「启动失败回滚上一次升级」的职责,该职责必须**早于所有模块**。 若排在后面:升级后新内核在 memory/llm 阶段崩了,而回滚还没跑 ⇒ 机器进 崩溃循环,只能靠 guard 的 failback(受限启动)兜底。failback 是最后手段, 不该承担"换错二进制"这种**已知可回滚**的故障。 ⇒ Requires() 为空 ⇒ 依赖图天然把它放 Layers[0]。 ## 两段式升级确认(rollback.go) 升级成功 ──→ 写 pending 标记("这个版本尚未被确认能启动") ↓ 重启 → 内核启动 → update 模块(第 0 层)读标记 ↓ ┌────────────┴────────────┐ 标记还在 标记已不在 (上次升级没能活到清除) (上次升级已确认成功) ↓ ↓ 回滚到备份版本 正常继续 + 清标记 清除时机:模块跑完自检后立刻清。只依赖"进程活着且执行到第 0 层", 不依赖其他模块 ⇒ 不会误判。 **为什么不用 TTL / 重启次数**:那是 guard 已有的 failback,但判据是 "崩了几次",不是"这次升级该不该撤销"。用重启次数会在"新版本偶发崩溃"时 误回滚 —— 而那种情况该修的是崩溃,不是回退版本。 ## 分层(与仓库其他模块同一惯例) internal/update/ 业务实现(三端共用:CLI/WebAPI/agent 工具) internal/kernelmod/update/ 适配层(读 ks → 调业务包 → 写回 ks) 参照 supervisor 模块:实现在 internal/supervisor,模块里只做装配。 分开的理由:internal/update 被三端调用,它们都没有 KernelState。 ## 标记写入时机(一个真实的顺序坑) MarkPendingUpgrade 在**替换二进制之前**调用。反过来(替换后才写)会在 "替换成功但没来得及写标记就崩了"时丢标记 ⇒ 下次启动不知道该回滚。 且写标记失败 ⇒ **放弃本次升级**(未替换任何文件):没有标记就没有回滚 能力,而不带回滚的升级等于赌运气。 ## 回滚失败必须响亮 备份不存在时返回 error,调用方(模块 Init)必须中止启动 —— 继续装配等于 用一个已知起不来的版本。错误文案带备份路径与标记路径(删标记即可放弃回滚), 人能照着修。 ## 判据 internal/update/rollback_test.go 10 条,2 条经变异验证: · TestRollback_RestoresBackup —— 二进制+depgraph 同批回滚、标记清除、 坏版本留档 homed-broken-*、版本轨迹记录 变异:回滚后不清标记 ⇒ 红 · TestRollback_MissingBackupIsLoud —— 备份缺失时报错且可执行 变异:静默返回 nil ⇒ 红 · TestPendingMarker_AbsentIsNotError —— 无标记返回 (nil,nil) 而非 error (否则每次启动都走"回滚失败"分支 ⇒ 内核永远起不来) · TestPendingMarker_CorruptIsActionable —— 标记损坏时提示可删 · TestStateSummary_* —— 回滚信息进 healthcheck;nil 安全 ## 顺带修正的既有判据 · runtime_dep_test.go:L0 设施集合从 2 个变 3 个(新增 kernel.version), 改为断言**集合**而非顺序(注册表本就是乱序的) · facilities.go:新增 FacVersion,并登记为 internalOnlyFacilities (它是启动时序事实,不是插件契约 —— facilities_sync_test 的守卫正确拦住了 "把内部标记泄漏成对外契约") 三个 DoUpgrade 调用点补 dataDir 参数(标记落点必须与内核 --data 一致)。
219 lines
7.1 KiB
Go
219 lines
7.1 KiB
Go
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("应记录远端版本")
|
||
}
|
||
}
|