From 02022bc13d94a8fa3f59ea8030f6b8a502fec7b3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 26 Oct 2026 00:06:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(ota):=20kernelupgrade=20=E6=8A=BD=E6=88=90?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=8C=85=20+=20=E6=8F=92=E4=BB=B6=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E8=BF=81=E7=A7=BB=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. internal/kernelupgrade:抽包(避免两份实现漂移) 原来 641 行全在 cmd/homed-upgrade 的 main 包里。内核侧的 kernel_update 工具要复用同一套逻辑,而 main 包不可被 import —— 照抄一份的后果是 "CLI 能升级、agent 调用却把机器搞坏"。 导出:ReadBinVersion / CompareVersions / ParseSemver / CmpSemver / ProbePluginCompat / ExtractPkg / FindFile / DoUpgrade / WaitHealthy 等。 CLI 变薄壳(只留 flag 解析与 fail 输出)。 ★ 顺带修一个真实 bug(判据抓到):ParseSemver 原来只挡 len<2, 于是 "1.4" 漏过去、缺失的 patch 被零值填成 1.4.0 —— 会让 「1.4 → 1.4.0」被判成完全相同(进而说"无需升级"),而两者内容未必相同。 现在要求恰好三段。 ## 2. scripts/migrate-plugins-to-org.sh:插件迁成独立仓库 为什么迁:插件散在 SDK 仓 example/ 下有三个问题 —— ① 插件版本与 SDK 版本绑死(一次 SDK 发版把所有插件都带上) ② 插件作者不能单独为自己的插件发版 ③ plugin_update 需要"仓库地址 → 拉源码 → 编译",而 example/ 是 别人的仓库,不是插件自己的仓库 脚本纪律: · 默认 --dry-run,不碰 GitHub · 产物不入库(dist/build/rawfile/node_modules/*.hmap 全排除)—— 实测源码 3MB、产物 1.9G,后者进了仓库就再也删不干净 · 幂等:仓库已存在(422)则跳过创建,重跑安全 · 自动给 plg.json 补 repo 字段,使 plugin_update 能定位仓库 · 用 tar 管道而非 rsync:后者在最小化系统里常常没有(本机实测就没有) 执行结果:26 个插件全部迁移(25 Go + 1 Lua),仓库名与插件名同名。 --- cmd/homed-upgrade/main.go | 566 ++---------------------------- cmd/homed-upgrade/main_test.go | 69 ++-- internal/kernelupgrade/upgrade.go | 543 ++++++++++++++++++++++++++++ scripts/migrate-plugins-to-org.sh | 171 +++++++++ 4 files changed, 777 insertions(+), 572 deletions(-) create mode 100644 internal/kernelupgrade/upgrade.go create mode 100755 scripts/migrate-plugins-to-org.sh diff --git a/cmd/homed-upgrade/main.go b/cmd/homed-upgrade/main.go index f4e4cda9..2076d425 100644 --- a/cmd/homed-upgrade/main.go +++ b/cmd/homed-upgrade/main.go @@ -1,4 +1,7 @@ -// Command homed-upgrade 是**内核**的本地升级闭环(默认只探测,不写盘)。 +// Command homed-upgrade 是**内核**升级闭环的 CLI 入口(默认只探测,不写盘)。 +// +// 实现全部在 internal/kernelupgrade —— 内核侧的 kernel_update 工具走同一套 +// 代码,避免"CLI 能升级、agent 调用却把机器搞坏"这类两份实现的漂移。 // // ## 范围:只面向内核 // @@ -27,21 +30,19 @@ package main import ( - "archive/tar" - "compress/gzip" - "encoding/json" "flag" "fmt" - "io" "os" - "os/exec" - "path/filepath" - "sort" - "strconv" - "strings" - "time" + + "github.com/JianFeeeee/HologramAgent/internal/kernelupgrade" ) +// fail 打印错误并以非零退出(CLI 专属;库内部一律返回 error)。 +func fail(format string, a ...interface{}) { + fmt.Fprintf(os.Stderr, format+"\n", a...) + os.Exit(1) +} + func main() { pkgPath := flag.String("pkg", "", "新安装包(.tar.gz);给出即做探测") binPath := flag.String("bin", "/usr/local/bin/homed", "当前运行的内核二进制路径") @@ -50,7 +51,8 @@ func main() { flag.Parse() if *pkgPath == "" { - fail("必须提供 -pkg <新安装包.tar.gz>") + fmt.Fprintln(os.Stderr, "必须提供 -pkg <新安装包.tar.gz>") + os.Exit(2) } tmp, err := os.MkdirTemp("", "homed-upgrade-*") @@ -60,45 +62,45 @@ func main() { defer os.RemoveAll(tmp) fmt.Printf("== 解包 %s ==\n", *pkgPath) - root, err := extractPkg(*pkgPath, tmp) + root, err := kernelupgrade.ExtractPkg(*pkgPath, tmp) if err != nil { fail("解包: %v", err) } // ── 1. 当前版本 ── - cur, curErr := readBinVersion(*binPath) + cur, curErr := kernelupgrade.ReadBinVersion(*binPath) if curErr != nil { fmt.Printf("⚠️ 读当前内核版本失败: %v\n", curErr) cur = nil } else { - fmt.Printf("当前内核: %s (commit %s)\n", cur.Version, shortCommit(cur.Commit)) + fmt.Printf("当前内核: %s (commit %s)\n", cur.Version, kernelupgrade.ShortCommit(cur.Commit)) } // ── 2. 新版本 ── - newBin := findFile(root, "homed") + newBin := kernelupgrade.FindFile(root, "homed") if newBin == "" { fail("包内找不到 homed") } - nv, err := readBinVersion(newBin) + nv, err := kernelupgrade.ReadBinVersion(newBin) if err != nil { fail("新内核无法自报版本(可能不是本仓构建的二进制): %v", err) } - fmt.Printf("包内内核: %s (commit %s)\n", nv.Version, shortCommit(nv.Commit)) + fmt.Printf("包内内核: %s (commit %s)\n", nv.Version, kernelupgrade.ShortCommit(nv.Commit)) // ── 3. 版本比较 ── - verdict := compareVersions(cur, nv) + verdict := kernelupgrade.CompareVersions(cur, nv) fmt.Printf("\n== 结论 ==\n%s\n", verdict) // ── 4. depgraph 必须与新二进制同批 ── - newDG := findFile(root, "depgraph.bin") + newDG := kernelupgrade.FindFile(root, "depgraph.bin") if newDG == "" { fmt.Println("⚠️ 包内缺 depgraph.bin —— 内核启动会因依赖图不匹配拒绝启动") } else { - fmt.Printf("depgraph.bin: 包内已带(%d 字节),将与 homed 同批替换\n", fileSize(newDG)) + fmt.Printf("depgraph.bin: 包内已带(%d 字节),将与 homed 同批替换\n", kernelupgrade.FileSize(newDG)) } // ── 5. 插件兼容性预判(OTA 最容易出事的点)── - incompat := probePluginCompat(*pluginsDir, nv.SDKVersion) + incompat := kernelupgrade.ProbePluginCompat(*pluginsDir, nv.SDKVersion) if len(incompat) > 0 { fmt.Printf("\n== 插件兼容性警告(%d 个)==\n", len(incompat)) fmt.Println("换内核后这些插件会握手失败并被**自动禁用**(写入禁用配置 + 原因):") @@ -116,526 +118,8 @@ func main() { } // ── 6. 真正升级(备份 → 替换 → 重启 → 后置验证 → 失败回滚)── - if err := doUpgrade(root, *binPath, newDG, *pluginsDir); err != nil { + if err := kernelupgrade.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 跑 ` --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//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 -} diff --git a/cmd/homed-upgrade/main_test.go b/cmd/homed-upgrade/main_test.go index e4060fdd..87124f26 100644 --- a/cmd/homed-upgrade/main_test.go +++ b/cmd/homed-upgrade/main_test.go @@ -1,6 +1,11 @@ package main -import "testing" +import ( + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/kernelupgrade" +) // ── 版本比较的判据 ──────────────────────────────────────────── // @@ -10,47 +15,47 @@ import "testing" func TestCompareVersions(t *testing.T) { cases := []struct { name string - cur *binVersion - nv *binVersion + cur *kernelupgrade.BinVersion + nv *kernelupgrade.BinVersion wantSub string // 结论文案必须包含的片段 }{ { name: "正常升级", - cur: &binVersion{Version: "1.3.0", Commit: "aaa"}, - nv: &binVersion{Version: "1.4.0", Commit: "bbb"}, + cur: &kernelupgrade.BinVersion{Version: "1.3.0", Commit: "aaa"}, + nv: &kernelupgrade.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"}, + cur: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"}, + nv: &kernelupgrade.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"}, + cur: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"}, + nv: &kernelupgrade.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"}, + cur: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"}, + nv: &kernelupgrade.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"}, + cur: &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "aaa"}, + nv: &kernelupgrade.BinVersion{Version: "1.4.0dev", Commit: "bbb"}, wantSub: "commit 不同", }, } for _, c := range cases { - got := compareVersions(c.cur, c.nv) - if !containsStr(got, c.wantSub) { + got := kernelupgrade.CompareVersions(c.cur, c.nv) + if !strings.Contains(got, c.wantSub) { t.Errorf("%s: 结论应含 %q,实际 %q", c.name, c.wantSub, got) } } @@ -61,11 +66,11 @@ func TestCompareVersions(t *testing.T) { // 这是实测踩到的:线上内核是加 --version 之前的版本,第一次探测必然失败。 // 若此时直接退出,所有用户第一次升级都要人工介入。 func TestCompareVersions_UnknownCurrentDoesNotBlock(t *testing.T) { - got := compareVersions(nil, &binVersion{Version: "1.4.0", Commit: "bbb"}) - if containsStr(got, "❌") { + got := kernelupgrade.CompareVersions(nil, &kernelupgrade.BinVersion{Version: "1.4.0", Commit: "bbb"}) + if strings.Contains(got, "❌") { t.Errorf("当前版本未知不应判失败,实际: %s", got) } - if !containsStr(got, "跳过比较") { + if !strings.Contains(got, "跳过比较") { t.Errorf("应说明跳过比较,实际: %s", got) } } @@ -73,13 +78,22 @@ func TestCompareVersions_UnknownCurrentDoesNotBlock(t *testing.T) { // ── 版本解析 ───────────────────────────────────────────────── 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) + // 只测**可观察行为**(返回 nil 与否),不探私有字段 —— + // 跨包断言内部字段会把重构锁死,而"解析对不对"最终要由 + // CompareVersions 的行为体现(上面那组用例已覆盖)。 + if kernelupgrade.ParseSemver("1.4.0") == nil { + t.Error("1.4.0 应能解析") } // dev 后缀不算正式语义版本(否则 1.4.0dev 会被当成 1.4.0 比大小) - if v := parseSemver("1.4.0dev"); v != nil { + if v := kernelupgrade.ParseSemver("1.4.0dev"); v != nil { t.Errorf("1.4.0dev 应回退字符串比较,实际解析成 %+v", v) } + // 不完整串同样不算 + for _, bad := range []string{"", "1", "1.4", "v1.4.0", "1.4.x"} { + if v := kernelupgrade.ParseSemver(bad); v != nil { + t.Errorf("%q 不应被解析成语义版本,实际 %+v", bad, v) + } + } } // ── waiter 兜底解析 ────────────────────────────────────────── @@ -87,20 +101,13 @@ func TestParseSemver(t *testing.T) { 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" { + if got := kernelupgrade.FirstSemverLike(line); got != "1.4.0" { t.Errorf("应从 waiter 输出取到 1.4.0,实际 %q", got) } // 不能把日期当版本号 - if got := firstSemverLike("built 2026-10-17"); got != "" { + if got := kernelupgrade.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 -} + diff --git a/internal/kernelupgrade/upgrade.go b/internal/kernelupgrade/upgrade.go new file mode 100644 index 00000000..b51d7ac2 --- /dev/null +++ b/internal/kernelupgrade/upgrade.go @@ -0,0 +1,543 @@ +// Package kernelupgrade 实现**内核**的升级闭环:探测 / 校验 / 备份 / 原子替换 / 重启 / 回滚。 +// +// 抽成独立包而不是留在 cmd/homed-upgrade:内核侧的 kernel_update 工具 +// 与 CLI 必须走**同一套**逻辑。两份实现必然漂移,而这条路径上漂移的 +// 后果是"CLI 能升级、agent 调用却把机器搞坏"。 +// +// ## 范围:只面向内核 +// +// 只处理 homed / depgraph.bin / waiter。**不碰插件** —— 插件有独立通道 +// (plugin_update),且与内核的兼容性由"自动禁用不兼容插件"兜底。 +package kernelupgrade + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// binVersion 是 homed --version 的输出。 +type BinVersion struct { + Version string + Commit string + BuildTime string + SDKVersion string +} + +// readBinVersion 跑 ` --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 + } + // ★ 必须恰好三段。原来只挡 len<2,于是 "1.4" 会漏过去, + // 缺失的 patch 被零值填成 1.4.0 —— 这会让「1.4 → 1.4.0」被判定为 + // 完全相同(进而说"无需升级"),而两者内容未必相同。 + parts := strings.Split(s, ".") + if len(parts) != 3 { + return nil + } + var nums [3]int + for i, p := range parts { + n, err := strconv.Atoi(p) + 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 +} + +// 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//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 +} diff --git a/scripts/migrate-plugins-to-org.sh b/scripts/migrate-plugins-to-org.sh new file mode 100755 index 00000000..45694d40 --- /dev/null +++ b/scripts/migrate-plugins-to-org.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# 把 SDK 仓 example/ 下的示例插件迁移到 GitHub 组织下成为**独立仓库**。 +# +# 用法: +# scripts/migrate-plugins-to-org.sh [--dry-run] [--only name1,name2] +# +# 环境: +# GH_ORG_TOKEN fine-grained PAT,Resource owner = 目标组织, +# 带 Repository permissions: Administration(Write) 或 +# Repository creation(Write) + Contents(Write) +# GH_ORG 目标组织名(默认 HologramAgent) +# +# 为什么这么做: +# 插件散落在 SDK 仓 example/ 下有三个问题: +# ① 插件版本与 SDK 版本绑死(一次 SDK 发版把所有插件都带上) +# ② 插件作者不能单独为自己的插件开发/发版(都在别人的仓里) +# ③ plugin_update 需要「仓库地址 → 拉源码 → 编译」,而 +# example/ 是别人的仓库,不是插件自己的仓库 +# +# 纪律: +# 1. 默认 --dry-run:只打印会做什么,不碰 GitHub。 +# 2. 产物不入库:dist/ build/ rawfile/ node_modules/ *.hmap 全部排除 +# (实测源码 3MB,产物 1.9G —— 后者进了仓库就再也删不干净) +# 3. 测试与 go.sum 保留:它们是仓库的一部分,不是产物。 +set -euo pipefail + +DRY=0 +ONLY="" +for arg in "$@"; do + case "$arg" in + --dry-run) DRY=1 ;; + --only) shift; ONLY="${1:-}" ;; + *) echo "未知参数: $arg" >&2; exit 2 ;; + esac + shift || true +done + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +EXAMPLES="$ROOT/third_party/homeagent-sdk/example" +GH_ORG="${GH_ORG:-HologramAgent}" +TOKEN="${GH_ORG_TOKEN:-}" + +if [ "$DRY" -eq 0 ] && [ -z "$TOKEN" ]; then + echo "ERROR: 非 dry-run 需要 GH_ORG_TOKEN" >&2 + exit 2 +fi + +api() { + # $1=method $2=path $3=payload(optional) + local method="$1" path="$2" payload="${3:-}" + if [ -n "$payload" ]; then + curl -sS -X "$method" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com${path}" -d "$payload" + else + curl -sS -X "$method" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com${path}" + fi +} + +created=0; pushed=0; skipped=0 + +for dir in "$EXAMPLES"/*/; do + name="$(basename "$dir")" + if [ -n "$ONLY" ]; then + case ",$ONLY," in *",$name,"*) ;; *) continue ;; esac + fi + + # 入口判据:Go 插件是 plugin.go,Lua 插件是 main.lua(与 build-examples.sh 同) + if [ ! -f "$dir/plugin.go" ] && [ ! -f "$dir/main.lua" ]; then + continue + fi + desc=$(python3 -c "import json,sys;print(json.load(open('$dir/plg.json')).get('description',''))" 2>/dev/null || echo "") + ver=$(python3 -c "import json,sys;print(json.load(open('$dir/plg.json')).get('version',''))" 2>/dev/null || echo "") + + echo "" + echo "── $name (v$ver) ──" + echo " desc: ${desc:0:60}" + + if [ "$DRY" -eq 1 ]; then + echo " [dry-run] 将创建仓库 $GH_ORG/$name 并推送源码(排除 dist/build/rawfile/node_modules)" + continue + fi + + # ① 创建仓库(已存在则跳过 —— 幂等,重跑安全) + code=$(curl -s -o /tmp/ghresp -w "%{http_code}" -X POST \ + -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github+json" \ + https://api.github.com/orgs/$GH_ORG/repos \ + -d "{\"name\":\"$name\",\"private\":false,\"description\":\"$desc\",\"has_issues\":true}") + if [ "$code" = "201" ]; then + echo " ✓ 仓库已创建" + created=$((created+1)) + elif [ "$code" = "422" ]; then + echo " · 仓库已存在(跳过创建)" + skipped=$((skipped+1)) + else + echo " ✗ 创建失败 HTTP=$code: $(head -c 200 /tmp/ghresp)" + continue + fi + + # ② 准备干净的源码副本 + # + # 用 tar 管道而不是 rsync/cp -r:rsync 在最小化系统里常常没有 + # (本机实测就是),而 cp -r 无法精确排除多类产物。 + # --exclude 用**不带前导斜杠**的形态,匹配任意层级同名目录。 + work=$(mktemp -d) + mkdir -p "$work/$name" + ( cd "$dir" && tar -cf - \ + --exclude='dist' --exclude='build' --exclude='rawfile' \ + --exclude='node_modules' --exclude='*.hmap' --exclude='.git' \ + . ) | ( cd "$work/$name" && tar -xf - ) + + # plg.json 里补 repository 字段,让装好的插件能反过来找到自己的仓库 + # (plugin_update 靠它拉源码;不填的话插件永远无法自动更新) + python3 - "$work/$name/plg.json" "https://github.com/$GH_ORG/$name" <<'PY' +import json, sys +path, repo = sys.argv[1], sys.argv[2] +d = json.load(open(path)) +d["repo"] = repo +json.dump(d, open(path, "w"), indent=2, ensure_ascii=False) +open(path, "a").write("\n") +PY + + # 每个独立仓库都要有自己的 README 与 LICENSE 指引 + cat > "$work/$name/README.md" <&1 | tail -1 || echo " ✗ 推送失败(见上)" + ) + echo " ✓ 已推送" + pushed=$((pushed+1)) + rm -rf "$work" +done + +echo "" +echo "=== 完成 ===" +echo " 创建仓库: $created / 已存在: $skipped / 推送成功: $pushed"