diff --git a/cmd/homed-upgrade/main.go b/cmd/homed-upgrade/main.go index 44a1a15c..dad9ece1 100644 --- a/cmd/homed-upgrade/main.go +++ b/cmd/homed-upgrade/main.go @@ -33,8 +33,10 @@ import ( "flag" "fmt" "os" + "os/exec" "path/filepath" "strings" + "time" "github.com/JianFeeeee/HologramAgent/internal/update" ) @@ -67,6 +69,7 @@ func main() { binPath := flag.String("bin", "/usr/local/bin/homed", "当前运行的内核二进制路径") pluginsDir := flag.String("plugins", "/home/newqqagent/plugins", "外部插件目录(探测兼容性用)") apply := flag.Bool("apply", false, "真正执行升级(默认只探测+预检)") + unit := flag.String("unit", "homeagent", "重启用的 systemd 单元名(仅 -apply 时使用)") flag.Parse() if *pkgPath == "" && !*release { @@ -146,8 +149,50 @@ func main() { return } - // ── 6. 真正升级(备份 → 替换 → 重启 → 后置验证 → 失败回滚)── - if err := update.DoUpgrade(root, *binPath, newDG, *pluginsDir, upgradeDataDir(*binPath)); err != nil { + // ── 6. 真正升级:预检 → 备份 → pending → 原子替换(**不含重启**)── + out, err := update.Apply(root, *binPath, newDG, upgradeDataDir(*binPath), nil) + if err != nil { fail("升级失败: %v", err) } + fmt.Printf("\n新内核已就位:%s → %s\n备份保留在 %s\n", out.From.Version, out.To.Version, out.BackupDir) + + // ── 7. 重启服务并核对版本 ── + // + // ★ 为什么 CLI 可以停下服务:它**不在**被守护的 cgroup 里,是外部管理命令, + // 与"worker 自己 systemctl restart 自杀"是两件事。 + // 服务在跑时优先让它走既有重启通道(guard 重拉、不计失败轮次)—— + // 那需要 WebAPI 的 api_key,这里只做最朴素的外部重启,并如实报告结果。 + fmt.Printf("\n== 重启服务(unit %s)==\n", *unit) + if rerr := restartUnit(*unit); rerr != nil { + fmt.Printf("⚠️ 重启失败: %v\n", rerr) + fmt.Printf(" 新内核已在磁盘上就位,pending 标记也已写入:\n") + fmt.Printf(" · 服务下次启动时会自动确认新版本;若新版本起不来,第 0 层 update 模块会自动回滚\n") + fail("需要手动重启服务才能生效") + } + + // 版本复核:等二进制自报版本变成期望值(最多 60s)。 + // 用版本而不是进程状态:只有版本能证明"跑起来的确实是新的"。 + deadline := time.Now().Add(60 * time.Second) + for time.Now().Before(deadline) { + if v, verr := update.ReadBinVersion(*binPath); verr == nil && + v.Version == nv.Version && v.Commit == nv.Commit { + fmt.Printf("\n✅ 升级完成:%s (commit %s)\n", v.Version, update.ShortCommit(v.Commit)) + return + } + time.Sleep(time.Second) + } + if v, verr := update.ReadBinVersion(*binPath); verr == nil { + fmt.Printf("⚠️ 版本复核未通过:期望 %s/%s,实际 %s/%s\n", + nv.Version, update.ShortCommit(nv.Commit), v.Version, update.ShortCommit(v.Commit)) + } + fail("重启后未能确认新版本已生效(若服务起不来,下次启动会自动回滚)") +} + +// restartUnit 重启 systemd 单元(外部管理动作)。 +func restartUnit(unit string) error { + out, err := exec.Command("systemctl", "restart", unit).CombinedOutput() + if err != nil { + return fmt.Errorf("%v (%s)", err, strings.TrimSpace(string(out))) + } + return nil } diff --git a/internal/events/bus.go b/internal/events/bus.go index 1a7f254b..47588e99 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -109,6 +109,25 @@ const ( // detail: 该步的人读说明 EventUpdateProgress EventType = "update_progress" + // EventUpdateRequest 是**其他插件请求更新**的入口。 + // + // ## 为什么用事件而不是让各插件直接 import internal/update + // + // 请求方只需说"我要更新",不必知道怎么拉源码、怎么编译、怎么替换、 + // 怎么回滚。那些是本子系统的事。用事件解耦后: + // + // · 请求方连 import 都不需要(外置插件更不可能 import 内核内部包) + // · 请求与进度都在总线上,WebUI 能显示"谁请求 → 到哪一步 → 结果如何" + // · 多个插件同时请求时天然按到达顺序处理,不会两个 git clone 抢同一工作目录 + // + // payload 约定: + // + // scope: "kernel" | "plugin" + // name: 插件名(scope=plugin 时必填) + // pkg: 本机安装包路径(可选) + // release: bool,从仓库最新 Release 自动取包(可选) + EventUpdateRequest EventType = "update_request" + // EventUpdateResult 发布升级的最终结果(成功或失败,含回滚轨迹)。 // // payload 约定: diff --git a/internal/kernelboot/kernelstate.go b/internal/kernelboot/kernelstate.go index 5b3dc0e4..0dd0015e 100644 --- a/internal/kernelboot/kernelstate.go +++ b/internal/kernelboot/kernelstate.go @@ -173,6 +173,16 @@ type KernelState struct { MultimodalProviderName string MultimodalErr string + // Update 是更新子系统的公共数据(update 模块产出)。 + // + // 用接口而不是具体类型:kernelboot 不能 import internal/update + // (那会形成 kernelboot → update → kernelboot 的循环)。 + // 模块在 Init 里赋 *update.State,读取方用类型断言取回。 + // + // ★ 它在依赖图第 0 层(update 模块 Requires 为空),所以任何模块 + // 读它时都已就绪 —— 包括"启动失败回滚"的判定结果。 + Update UpdateStateProvider + // StageHost 是阶段管道(stage-host 模块产出)。 StageHost *agentCore.StageHost @@ -328,3 +338,12 @@ func (s *KernelState) CloseAll() error { } return nil } + +// UpdateStateProvider 是更新子系统暴露给内核的最小接口。 +// +// 定义在 kernelboot 而不是 internal/update:避免 kernelboot → update 的 +// import 循环(update 的模块实现要 import kernelboot)。 +type UpdateStateProvider interface { + // Summary 返回给 healthcheck / 状态面的版本摘要。 + Summary() map[string]interface{} +} diff --git a/internal/plugin/kernel_control_gate_test.go b/internal/plugin/kernel_control_gate_test.go index bffb8ece..b3780976 100644 --- a/internal/plugin/kernel_control_gate_test.go +++ b/internal/plugin/kernel_control_gate_test.go @@ -44,13 +44,36 @@ func TestKernelControlOnlyForAllowlistedPlugins(t *testing.T) { if got := r.kernelControlFor("webui"); got == nil { t.Fatal("★ webui 应拿到内核控制通道(它是有鉴权的管理界面)") } - // 典型的内置插件都不该拿到 - for _, name := range []string{"cmd", "files", "memo", "healthcheck", "pluginmgr", "timer"} { + // 典型的内置插件都不该拿到**完整**通道(能关机) + for _, name := range []string{"cmd", "files", "memo", "healthcheck", "timer"} { if got := r.kernelControlFor(name); got != nil { t.Fatalf("★ 内置插件 %q 拿到了内核控制通道 —— "+ "任何插件都能关机整个服务,攻击面失控", name) } } + + // ★★★ pluginmgr 只拿到**仅重启**通道(kernelRestartAllowed): + // + // 为什么需要它:kernel_update 已经把新二进制换好,不重启等于升级 + // 永远不生效,而 pending 标记会在下次启动时被判"未确认"并回滚。 + // 为什么不能给完整通道:关机后服务不会自己回来(压制 Restart=always), + // 而重启几秒后 guard 会自己拉回来 —— 两者危险程度差一个量级。 + pm := r.kernelControlFor("pluginmgr") + if pm == nil { + t.Fatal("★ pluginmgr 应拿到**仅重启**通道(否则 kernel_update 换完二进制却不重启)") + } + if err := pm.RequestKernelShutdown("t"); err == nil { + t.Fatal("★★★ pluginmgr 竟然能关机整个服务 —— 攻击面失控(只该授予重启)") + } + if fake.shutdownCalls != 0 { + t.Fatalf("★ 关机请求被转发到底层通道(%d 次)—— 包装没有拦住", fake.shutdownCalls) + } + if err := pm.RequestKernelRestart("t"); err != nil { + t.Fatalf("pluginmgr 应能重启: %v", err) + } + if fake.restartCalls != 1 { + t.Fatalf("重启请求未到达底层通道(%d)", fake.restartCalls) + } // 未知/外部插件名 if got := r.kernelControlFor("some_third_party"); got != nil { t.Fatal("★ 未登记插件拿到了内核控制通道") diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index bb0b57b6..6f062c99 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -293,6 +293,36 @@ var kernelControlAllowed = map[string]bool{ "webui": true, } +// kernelRestartAllowed 是**仅重启**授权(不含关机)。 +// +// ## 为什么必须把这两件事分开 +// +// 完整通道的两个动作危险程度差一个量级: +// +// RequestKernelRestart guard 用磁盘上的二进制重拉 ⇒ 几秒后自己回来,**可恢复** +// RequestKernelShutdown 压制 Restart=always ⇒ 服务停住,**不会自己回来** +// +// 而 kernel_update 的收尾只需要"重拉":新二进制已经换好了,不重启等于升级 +// 永远不生效 —— 更糟的是 pending 标记会在下次启动时被判"未确认"并**回滚**。 +// +// ⇒ 给 pluginmgr 一个**去掉关机**的通道。攻击面因此限于"能让服务重启", +// +// 而不是"能让服务消失"。 +var kernelRestartAllowed = map[string]bool{ + "pluginmgr": true, +} + +// restartOnly 是去掉关机能力的控制通道包装。 +type restartOnly struct{ full sdk.KernelControlAPI } + +func (r restartOnly) RequestKernelRestart(reason string) error { + return r.full.RequestKernelRestart(reason) +} + +func (r restartOnly) RequestKernelShutdown(reason string) error { + return fmt.Errorf("该插件只被授予\"重启\"能力,未被授予关机能力(kernelRestartAllowed)") +} + // SetKernelControl 注入内核重启/关机通道(由 cmd/homed 装配时调用)。 // // ★ 为何用 setter 而不是构造参数:internal/plugin 不能 import cmd/homed @@ -320,10 +350,16 @@ func (r *Registry) kernelControlFor(name string) sdk.KernelControlAPI { // 拆出来的原因:buildSDK 要在**一次** RLock 里取走整组依赖(见 snapshotDeps), // 不能再回头单独取锁 —— RWMutex 不可重入,持锁再 RLock 在写者排队时会自锁。 func kernelControlForCtl(ctl sdk.KernelControlAPI, name string) sdk.KernelControlAPI { - if ctl == nil || !kernelControlAllowed[name] { + if ctl == nil { return nil } - return ctl + if kernelControlAllowed[name] { + return ctl + } + if kernelRestartAllowed[name] { + return restartOnly{full: ctl} + } + return nil } func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { diff --git a/internal/plugins/pluginmgr/kernel_update.go b/internal/plugins/pluginmgr/kernel_update.go index 57976e13..8f0f6b31 100644 --- a/internal/plugins/pluginmgr/kernel_update.go +++ b/internal/plugins/pluginmgr/kernel_update.go @@ -20,8 +20,12 @@ // ## 两阶段(用户口径) // // apply=false(默认)→ 只查 GitHub Release 与本地版本比较,不改动任何东西 -// apply=true → 下载/解包 → 校验 → 备份 → 原子替换 → 重启 → 版本复核 -// → 失败自动回滚(DoUpgrade 内部完成) +// apply=true → 解包 → 校验 → 备份 → 原子替换(**不重启**) +// → 请求 guard 重拉 → 新内核启动时由 update 模块确认或回滚 +// +// ★ 重启为什么不在 internal/update 里做:worker 是被守护方,只有 guard 知道 +// 这次退出该不该重拉(exit 42 → 重拉且不计失败轮次)。详见 +// cmd/homed/kernel_control.go。 package pluginmgr @@ -39,33 +43,37 @@ func (p *Plugin) kernelUpdateCheck() (interface{}, error) { cur := currentKernelVersion(p) info := update.CheckRelease(cur) return map[string]interface{}{ - "apply": false, - "current": info.Current, - "latest": info.Latest, + "apply": false, + "current": info.Current, + "latest": info.Latest, "has_update": info.HasUpdate, - "asset": info.Asset, - "release": info.LatestURL, - "reason": info.Reason, - "hint": "确认后传 apply=true 执行升级(会替换二进制并重启内核,连接断开数秒)", + "asset": info.Asset, + "release": info.LatestURL, + "reason": info.Reason, + "hint": "确认后传 apply=true 执行升级(会替换二进制并重启内核,连接断开数秒)", }, nil } // kernelUpdateApply 是 kernel_update 的执行半边(apply=true)。 // -// 只接受**本机已有的包**(pkg 参数):从 URL 现场下载需要处理 -// 校验/断点/重试,那是 CLI 的职责(homed-upgrade),agent 不应在 -// 推理过程中发起长下载(会占满工具调用超时)。 -func (p *Plugin) kernelUpdateApply(pkgPath string) (interface{}, error) { - if pkgPath == "" { - return map[string]interface{}{ - "error": "apply=true 需要 pkg(本机已存在的安装包路径;下载请跑 homed-upgrade -pkg )", - }, nil - } - st, err := os.Stat(pkgPath) - if err != nil || st.IsDir() { - return map[string]interface{}{ - "error": fmt.Sprintf("pkg 无效:%s", pkgPath), - }, nil +// 来源二选一(update.ParseSource): +// +// · pkg 非空 → 用本机已有的安装包 +// · release=true → 从仓库最新 GitHub Release 自动下载 +// +// ★ 顺序(此前是反的,会出真事故): +// +// 先 Apply(备份→pending→替换),**再**请求重启。 +// 反过来(先请求重启)会在新二进制还没就位时就重拉, +// 或重启恰好发生在替换中途 ⇒ 跑起来的是半新半旧的文件。 +// +// ★ 重启走既有的 p.sdk.KernelControl()(restartCh → exit 42 → guard 重拉)。 +// 它依赖 kernelControlAllowed 白名单 —— pluginmgr 已加入(否则该接口恒为 nil, +// 以前写在那里等于静默不重启)。 +func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, error) { + src, err := update.ParseSource(pkgPath, release) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil } tmp, err := os.MkdirTemp("", "kernel-upgrade-*") @@ -74,39 +82,62 @@ func (p *Plugin) kernelUpdateApply(pkgPath string) (interface{}, error) { } defer os.RemoveAll(tmp) - root, err := update.ExtractPkg(pkgPath, tmp) + root, rel, err := src.Resolve(tmp) if err != nil { - return nil, fmt.Errorf("解包: %w", err) + return map[string]interface{}{"error": "取包失败: " + err.Error()}, nil } newBin := update.FindFile(root, "homed") newDG := update.FindFile(root, "depgraph.bin") if newBin == "" { - return nil, fmt.Errorf("包内找不到 homed") + return map[string]interface{}{"error": "包内找不到 homed"}, nil } - // ★ 升级会让本进程消失:先把"已受理"回给模型,再执行替换。 - // - // 否则模型看到的是工具调用超时/断线,无法区分"升级成功"和"工具坏了"。 - // 返回后由本 goroutine 继续跑 DoUpgrade(替换+重启+回滚)。 - if kc := p.sdk.KernelControl(); kc != nil { - _ = kc.RequestKernelRestart("kernel_update apply") + // 走事件总线:升级的每一步都让 WebUI 看得到。 + var bus *events.Bus + if p.sdk != nil { + bus = p.sdk.EventBus() } - go func() { - // 走事件总线:升级的每一步(含重启前的最后几步)都让 WebUI 看得到。 - // 顺序上 publish 紧贴动作之前 —— 进程消失后事件就发不出去了。 - bus := (*events.Bus)(nil) - if p.sdk != nil { - bus = p.sdk.EventBus() + + out, err := update.Apply(root, kernelBinPath(p), newDG, kernelDataDir(p), bus) + if err != nil { + return map[string]interface{}{"error": err.Error()}, nil + } + + // 产物已就位 ⇒ 走**既有**重启通道(KernelControlAPI → restartCh → exit 42 + // → guard 用新二进制重拉)。不自己 systemctl,也不另开通道。 + // + // ★ 顺序关键:Apply 完成**之后**才请求重启。反过来会在新二进制还没 + // 就位时重拉,或重启恰好落在替换中途。 + if kc := p.sdk.KernelControl(); kc != nil { + if rerr := kc.RequestKernelRestart("kernel_update apply"); rerr != nil { + return map[string]interface{}{ + "applied": true, + "error": "新内核已就位,但请求重启失败: " + rerr.Error(), + "hint": "手动重启服务即可;pending 标记会让首次启动自动确认或回滚", + }, nil } - if err := update.DoUpgradeBus(root, kernelBinPath(p), newDG, p.pluginDir, kernelDataDir(p), bus); err != nil { - // DoUpgrade 内部失败已自行回滚;这里只留一条诊断日志 - fmt.Fprintf(os.Stderr, "[pluginmgr] kernel_update upgrade failed (rolled back): %v\n", err) - } - }() - return map[string]interface{}{ - "accepted": true, - "message": "升级已受理:正在替换二进制并重启。完成后用 kernel_update(apply=false)确认新版本。", - }, nil + } else { + // 拿不到通道时**如实报告**,不要静默当成功:否则用户以为已升级, + // 而进程还在跑旧镜像;pending 标记会在下次重启时被判"未确认"→ 回滚。 + return map[string]interface{}{ + "applied": true, + "error": "新内核已就位,但本插件没有被授予内核控制通道(kernelControlAllowed),无法请求重启", + "hint": "手动重启服务完成升级;或改用 WebUI 的 /api/v1/kernel/update", + }, nil + } + + res := map[string]interface{}{ + "applied": true, + "from": out.From.Version, + "to": out.To.Version, + "backup": out.BackupDir, + "message": "新内核已就位,正在由 guard 重拉。重启后若新版本无法启动,第 0 层 update 模块会自动回滚。", + } + if rel != nil { + res["release"] = rel.Latest + res["asset"] = rel.Asset + } + return res, nil } // kernelDataDir 推导数据目录(升级确认标记的落点)。 @@ -114,8 +145,14 @@ func (p *Plugin) kernelUpdateApply(pkgPath string) (interface{}, error) { // 从 pluginDir 反推:/plugins ⇒ 上一级即 dataDir。 // 与内核 --data 的值一致,update 模块(第 0 层)才能读到同一个标记。 func kernelDataDir(p *Plugin) string { + // /plugins ⇒ 上一级即 dataDir。 + // + // ★ 只 Dir 一次。此前写成 Dir(Dir(...)),把 /home/newqqagent/plugins + // 算成 /home —— pending 标记落到 /home/kernel-upgrade-pending.json, + // 而第 0 层 update 模块从 ks.DataDir 读 ⇒ **永远读不到标记** ⇒ + // agent 发起的升级一旦启动失败就**不会回滚**(静默失效)。 if p.pluginDir != "" { - return filepath.Dir(filepath.Dir(p.pluginDir)) + return filepath.Dir(p.pluginDir) } return "/home/newqqagent" } @@ -142,3 +179,47 @@ func kernelBinPath(p *Plugin) string { } return "homed" } + +// onUpdateRequest 处理来自其他插件的更新请求(EventBus)。 +// +// 请求方不必知道"怎么更新",只需发一个事件: +// +// scope: "kernel" | "plugin" +// name: 插件名(scope=plugin 时必填) +// pkg: 本机安装包路径(可选) +// release: bool,从仓库最新 Release 自动取包(可选) +// +// 为什么必须**异步**:升级会替换二进制并(对内核)请求重启, +// 本进程随后消失。事件处理器是同步投递的(见 events.Bus.Publish), +// 在处理器里同步做完会让发布方卡住直到进程被杀。 +func (p *Plugin) onUpdateRequest(evt *events.Event) { + if evt == nil || evt.Payload == nil { + return + } + scope, _ := evt.Payload["scope"].(string) + name, _ := evt.Payload["name"].(string) + pkgPath, _ := evt.Payload["pkg"].(string) + release, _ := evt.Payload["release"].(bool) + + go func() { + var err error + switch scope { + case "kernel": + _, err = p.kernelUpdateApply(pkgPath, release) + case "plugin": + if name == "" { + err = fmt.Errorf("plugin 更新请求缺 name") + break + } + // 复用既有 plugin_update 的 apply 通路(范围即该插件名) + _, err = p.pluginUpdate(true, name) + default: + err = fmt.Errorf("未知更新范围 %q(来源 %s)", scope, evt.Source) + } + if err != nil { + // 事件是单向的,没有返回值可取 ⇒ 失败必须留日志, + // 否则请求方看到的就是"发了、没反应"。 + fmt.Fprintf(os.Stderr, "[pluginmgr] update_request (%s/%s) failed: %v\n", scope, name, err) + } + }() +} diff --git a/internal/plugins/pluginmgr/kernel_update_test.go b/internal/plugins/pluginmgr/kernel_update_test.go index 194ed945..432247ba 100644 --- a/internal/plugins/pluginmgr/kernel_update_test.go +++ b/internal/plugins/pluginmgr/kernel_update_test.go @@ -70,27 +70,31 @@ func TestKernelUpdate_CheckShape(t *testing.T) { } } -// 判据 4:kernel_update apply 缺 pkg ⇒ 明确报错并指向 CLI。 -func TestKernelUpdate_ApplyRequiresPkg(t *testing.T) { +// 判据 4:kernel_update apply 没给来源 ⇒ 明确报错(不许"猜一个默认动作")。 +// +// 旧判据要求错误里出现 homed-upgrade —— 那是"agent 不该在推理中发起长下载" +// 时期的限制。现在支持 release=true 自动取包(下载放在内核侧统一实现), +// 所以判据改为:必须说清"两种来源二选一"。 +func TestKernelUpdate_ApplyRequiresSource(t *testing.T) { p := &Plugin{pluginDir: t.TempDir()} - out, err := p.kernelUpdateApply("") + out, err := p.kernelUpdateApply("", false) if err != nil { t.Fatalf("应返回可呈现的错误: %v", err) } m := out.(map[string]interface{}) - if !strings.Contains(fmtSprint(m["error"]), "pkg") { - t.Errorf("错误应说明需要 pkg,实际: %v", m["error"]) + msg := fmtSprint(m["error"]) + if !strings.Contains(msg, "pkg") { + t.Errorf("错误应说明需要 pkg,实际: %v", msg) } - // 并应提示下载走 CLI(agent 不该在推理中发起长下载) - if !strings.Contains(fmtSprint(m["error"]), "homed-upgrade") { - t.Errorf("错误应指向 homed-upgrade,实际: %v", m["error"]) + if !strings.Contains(msg, "Release") && !strings.Contains(msg, "release") { + t.Errorf("错误应说明可从 Release 自动更新,实际: %v", msg) } } // 判据 5:kernel_update apply 的 pkg 是目录/不存在 ⇒ 拒绝。 func TestKernelUpdate_ApplyRejectsBadPkg(t *testing.T) { p := &Plugin{pluginDir: t.TempDir()} - out, _ := p.kernelUpdateApply(t.TempDir()) // 目录不是包 + out, _ := p.kernelUpdateApply(t.TempDir(), false) // 目录不是包 m := out.(map[string]interface{}) if _, hasErr := m["error"]; !hasErr { t.Errorf("目录不应被当作安装包,实际: %v", m) diff --git a/internal/plugins/pluginmgr/plugin.go b/internal/plugins/pluginmgr/plugin.go index 4ef7fde9..f8996b40 100644 --- a/internal/plugins/pluginmgr/plugin.go +++ b/internal/plugins/pluginmgr/plugin.go @@ -21,9 +21,10 @@ import ( "sync" "time" + "github.com/JianFeeeee/HologramAgent/internal/events" "github.com/JianFeeeee/HologramAgent/internal/plugin" - "github.com/JianFeeeee/HologramAgent/internal/update" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" + "github.com/JianFeeeee/HologramAgent/internal/update" ) // platformBinary 按当前 OS/ARCH 选择正确的插件二进制文件名。 @@ -97,6 +98,9 @@ type Plugin struct { httpAddr string // 本实例的监听地址(默认 defaultHTTPAddr;来自 settings) sdk *sdk.PluginSDK pluginDir string + + // unsubUpdateReq 注销 update_request 订阅(Stop 时调用)。 + unsubUpdateReq func() // maxPackageBytes 是单个插件包 **压缩后** 的上限(0 = 不限)。 // // 为什么是配置项而不是常量:内嵌运行时(node+pi 之类)就要 ~100MB, @@ -495,12 +499,26 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) { return p.pluginInfo(name) }) + // ── 订阅:其他插件的更新请求(EventBus,避免它们直接 import internal/update)── + // + // 为什么用事件而不是让插件调函数: + // · 请求方只说"我要更新",不必知道怎么拉源码/编译/替换/回滚 + // · 外置插件根本 import 不到内核内部包 + // · 请求与进度都在总线上,WebUI 能显示"谁请求 → 到哪步 → 结果如何" + // + // 注意:内核升级的**重启**仍走既有 KernelControlAPI(restartCh → exit 42 + // → guard 重拉),本订阅只负责"受理请求 + 执行替换"。 + if bus := s.EventBus(); bus != nil { + p.unsubUpdateReq = bus.Subscribe(events.EventUpdateRequest, p.onUpdateRequest) + } + s.RegisterTool("kernel_update", sdk.ToolDef{ Name: "kernel_update", Description: "检查/升级**内核**(homed 二进制)。两阶段:默认(apply=false)只查 GitHub Release " + - "与本地版本比较,不改动任何东西;看过结果后传 apply=true + pkg(本机已存在的安装包路径)" + - "真正升级:校验 → 备份 → 原子替换 → 重启 → 版本复核 → 失败自动回滚。" + - "升级会让内核重启,连接断开数秒。从 URL 下载新包请用 CLI 的 homed-upgrade。", + "与本地版本比较,不改动任何东西;看过结果后传 apply=true,并给出包来源二选一:" + + "pkg=本机安装包路径,或 release=true 从最新 GitHub Release 自动下载。" + + "升级流程:校验 → 备份 → 原子替换 → 由 guard 重拉 → 新内核启动时确认,失败自动回滚。" + + "升级会让内核重启,连接断开数秒。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -510,7 +528,11 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) { }, "pkg": map[string]interface{}{ "type": "string", - "description": "安装包在本机的路径(.tar.gz)。仅 apply=true 时需要", + "description": "安装包在本机的路径(.tar.gz)。与 release 二选一;给了 pkg 就优先用它", + }, + "release": map[string]interface{}{ + "type": "boolean", + "description": "true=从仓库最新 GitHub Release 自动下载安装包。未给 pkg 时可用", }, }, }, @@ -519,8 +541,9 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) { }, func(args map[string]interface{}) (interface{}, error) { apply, _ := args["apply"].(bool) pkgPath, _ := args["pkg"].(string) + release, _ := args["release"].(bool) if apply { - return p.kernelUpdateApply(pkgPath) + return p.kernelUpdateApply(pkgPath, release) } return p.kernelUpdateCheck() }) @@ -732,7 +755,6 @@ func (p *Plugin) pluginUpdate(apply bool, scope string) (interface{}, error) { }, nil } - // ======== HTTP API ======== func (p *Plugin) startHTTPServer() { diff --git a/internal/plugins/webui/handler_sse_protocol_test.go b/internal/plugins/webui/handler_sse_protocol_test.go index 168d499a..f37abb81 100644 --- a/internal/plugins/webui/handler_sse_protocol_test.go +++ b/internal/plugins/webui/handler_sse_protocol_test.go @@ -156,6 +156,13 @@ func TestEveryPublishedEventHasAnOutwardPath(t *testing.T) { // 插件间握手事件:clawhubadapter → skillmgr 移交 SKILL 条目, // 属内核内部的职责链,前端无需感知。 "skill_detected": "插件间握手(clawhubadapter → skillmgr),非对话流程", + // update_request 是**内核内部的控制事件**(插件 → pluginmgr): + // "我要更新",由 pluginmgr 的订阅者受理并执行替换。 + // + // 它不该进 SSE:前端要的是**结果**(update_progress / update_result + // 已在 subTypes 里),而不是"谁发了一个请求"。把它推给浏览器只会 + // 让界面多出一条没有下文的消息。 + "update_request": "插件间请求更新(pluginmgr 订阅处理),非 UI 通知", // tools_ready:全部插件加载完毕、工具注册齐的信号,供内核侧订阅者 // (如 seq 的开机序列)用。它是**内核内部**的就绪信号, // 不是对话流程事件,前端无需感知。 diff --git a/internal/plugins/webui/kernel_update.go b/internal/plugins/webui/kernel_update.go index 09ff464a..fe20cf2f 100644 --- a/internal/plugins/webui/kernel_update.go +++ b/internal/plugins/webui/kernel_update.go @@ -2,23 +2,36 @@ package webui // 内核更新的 HTTP 端点(GET 检查 / POST 升级)。 // -// ## 与 agent 工具同底层的架构约束 +// ## 三端共享同一底层(架构约束) // // agent 的 kernel_update 工具、本端点、CLI(homed-upgrade)三者共享 -// internal/update 的 CheckRelease / DoUpgrade —— 三份实现必然漂移, -// 会出现"WebUI 说有新版、agent 说没有"的分裂状态。 +// internal/update 的 CheckRelease / Apply —— 三份实现必然漂移,会出现 +// "WebUI 说有新版、agent 说没有"的分裂状态。 // 本文件只做 HTTP 形状转换,不含业务逻辑。 // -// ## 与 kernel/restart 的关系 +// ## 包来源二选一 // -// 升级 = 换二进制 + 重启。重启通道(kernelCtl)已存在且经过生产验证, -// 升级走同一通道触发重启,不自建第二条。 +// {"release": true} → 从仓库最新 GitHub Release 自动下载 +// {"pkg": "/path/to/pkg.tar.gz"} → 用本机已有的安装包(优先) +// +// ## 升级 = 换产物 + 重启,两件事**分开做** +// +// Apply(本进程内,同步) :校验 → 备份 → pending → 原子替换 +// 重启(交给内核控制通道) :kernelCtl.RequestKernelRestart → exit 42 +// → guard 用新二进制重拉(不计失败轮次) +// +// ★ 为什么 Apply 必须同步返回:替换是**可失败**的动作(空间不足、权限、 +// 包损坏)。异步的话这些错误全被吞掉,用户只看到"已受理"然后毫无变化。 +// 而重启可以异步 —— 它让本进程消失,本来就没法回给它结果。 +// +// ★ 为什么不用 systemctl restart:见 cmd/homed/kernel_control.go —— +// worker 是被守护方,只有 guard 知道这次退出该不该重拉。 import ( "encoding/json" + "log" "net/http" "os" - "os/exec" "github.com/JianFeeeee/HologramAgent/internal/events" "github.com/JianFeeeee/HologramAgent/internal/update" @@ -38,45 +51,85 @@ func (h *Handler) handleKernelUpdate(w http.ResponseWriter, r *http.Request) { // handleKernelUpdateCheck 处理 GET —— 只检查不改动。 func (h *Handler) handleKernelUpdateCheck(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeJSONError(w, http.StatusMethodNotAllowed, "只接受 GET") - return - } - cur := currentKernelVersion() - writeJSON(w, http.StatusOK, update.CheckRelease(cur)) + writeJSON(w, http.StatusOK, update.CheckRelease(currentKernelVersion())) } // handleKernelUpdateApply 处理 POST /api/v1/kernel/update —— 执行升级。 -// -// body: {"pkg": "/path/to/pkg.tar.gz"}(可选;缺省用最近一次检查到的 -// release 资产 URL 现场下载)。 func (h *Handler) handleKernelUpdateApply(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeJSONError(w, http.StatusMethodNotAllowed, "只接受 POST") + var body struct { + Pkg string `json:"pkg"` + Release bool `json:"release"` + } + // body 可为空(不合法就在下面报"必须指定来源"),所以解码失败不单独报错。 + _ = json.NewDecoder(r.Body).Decode(&body) + + src, err := update.ParseSource(body.Pkg, body.Release) + if err != nil { + writeJSONError(w, http.StatusBadRequest, err.Error()) return } - // ★ 升级会让进程重启:先把响应写回去(同 kernel/restart 的教训) - writeJSON(w, http.StatusAccepted, map[string]interface{}{ - "accepted": true, - "message": "升级已受理。内核将替换二进制并重启,连接会断开数秒。完成后刷新状态页确认新版本。", - }) - var body struct { - Pkg string `json:"pkg"` + binPath := update.KernelBinaryPath() + + tmp, err := os.MkdirTemp("", "kernel-upgrade-*") + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "建临时目录失败: "+err.Error()) + return } - _ = json.NewDecoder(r.Body).Decode(&body) + defer os.RemoveAll(tmp) + + root, rel, err := src.Resolve(tmp) + if err != nil { + writeJSONError(w, http.StatusBadGateway, "取包失败: "+err.Error()) + return + } + newDG := update.FindFile(root, "depgraph.bin") // WebUI 侧天然有总线(它就是 SSE 的上游):升级每一步都让界面看得到 var bus *events.Bus if h.sdk != nil { bus = h.sdk.EventBus() } - go runKernelUpgrade(body.Pkg, bus) + + out, err := update.Apply(root, binPath, newDG, dataDirOf(binPath), bus) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + + res := map[string]interface{}{ + "applied": true, + "from": out.From.Version, + "to": out.To.Version, + "backup": out.BackupDir, + "message": "新内核已就位,正在重启。新版本启动后由第 0 层 update 模块确认;无法启动会自动回滚。", + } + if rel != nil { + res["release"] = rel.Latest + res["asset"] = rel.Asset + } + + // 先把响应写回,再让内核消失(同 kernel/restart 的教训:不 Flush 会 + // 变成 connection reset,用户以为出错了)。 + writeJSON(w, http.StatusOK, res) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + // 重启走**既有**内核控制通道(restartCh → exit 42 → guard 用新二进制重拉)。 + // 不自己 systemctl,也不另开通道。 + if h.kernelCtl == nil { + log.Printf("[webui] 新内核已就位,但内核控制通道不可用(kernelControlAllowed 未授予 webui),无法请求重启") + return + } + if err := h.kernelCtl.RequestKernelRestart("kernel_update apply"); err != nil { + log.Printf("[webui] kernel restart request failed (新内核已就位,需手动重启): %v", err) + } } // currentKernelVersion 读本机 homed 的版本(尽力而为)。 func currentKernelVersion() *update.BinVersion { - bin := "/usr/local/bin/homed" + bin := update.KernelBinaryPath() if _, err := os.Stat(bin); err != nil { return nil } @@ -87,33 +140,13 @@ func currentKernelVersion() *update.BinVersion { return v } -// runKernelUpgrade 在后台执行完整升级闭环(含重启与回滚)。 +// dataDirOf 推导数据目录(升级确认标记的落点)。 // -// bus 为 nil 时退化成纯日志(该部署未接事件总线)。 -func runKernelUpgrade(pkgPath string, bus *events.Bus) { - if pkgPath == "" { - // 没给本地包:从最新 release 资产下载。 - // 下载与升级的完整闭环由 CLI 工具实现,这里复用同一二进制 - // (/usr/local/bin/homed-upgrade 与本进程同源构建,语义一致)。 - if _, err := exec.LookPath("homed-upgrade"); err != nil { - return - } - return // 下载逻辑见 CLI;此处仅受理本地包路径 +// 与内核 `--data` 必须一致,否则第 0 层 update 模块读不到 pending 标记。 +// 优先用环境变量显式指定(部署方知道自己在哪),否则退回本机生产路径。 +func dataDirOf(binPath string) string { + if d := os.Getenv("HOMEAGENT_DATA_DIR"); d != "" { + return d } - // 本地包:直接走共享底层(与 CLI 同代码路径) - tmp, err := os.MkdirTemp("", "kernel-upgrade-*") - if err != nil { - return - } - defer os.RemoveAll(tmp) - root, err := update.ExtractPkg(pkgPath, tmp) - if err != nil { - return - } - newBin := update.FindFile(root, "homed") - newDG := update.FindFile(root, "depgraph.bin") - if newBin == "" { - return - } - _ = update.DoUpgradeBus(root, "/usr/local/bin/homed", newDG, "/home/newqqagent/plugins", "/home/newqqagent", bus) + return "/home/newqqagent" } diff --git a/internal/update/kernel.go b/internal/update/kernel.go index 637f10a9..93e3caa0 100644 --- a/internal/update/kernel.go +++ b/internal/update/kernel.go @@ -246,31 +246,36 @@ func FileSize(p string) int64 { return 0 } -// doUpgrade 执行真实升级:备份 → 预检 → 原子替换 → 重启 → 后置验证 → 失败回滚。 +// Apply 执行真实升级:预检 → 备份 → 写 pending → 原子替换。 // -// 每一步失败都往回退,且**回滚后必须能正常启动**——这是本函数的核心承诺: -// 宁可升级失败,不可让机器起不来。 -func DoUpgrade(root, binPath, newDG, pluginsDir, dataDir string) error { - return DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir, nil) -} - -// DoUpgradeBus 同 DoUpgrade,但把每个步骤发布到事件总线。 +// ## ★ 本函数**不重启**内核 // -// ★ 为什么必须"先发事件再动作":升级的最后几步会让进程消失 -// (replace → restart)。顺序一反,最后几步的事件永远发不出去 —— -// 而那时用户最需要知道"升级到哪一步了/失败了/已回滚"。 +// 重启必须由**调用方**通过内核控制通道发起,理由见 +// cmd/homed/kernel_control.go 的说明:worker 是被守护方,只有 guard +// 知道这次退出该不该重拉(exit 42 → 重拉且不计失败轮次)。 // -// bus 为 nil 时退化成纯 printf(CLI 场景没有总线)。 -func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events.Bus) error { +// 本包若自己调 `systemctl restart`,会带来三个问题: +// +// · 杀掉整个 cgroup(含 guard)—— 把监督者一起杀掉 +// · 丢失 exit 42 语义:guard 的 failures/failbackRound 状态全丢 +// · 发命令的进程正是被重启杀掉的进程 ⇒ 返回值不可靠, +// 一次成功的升级会被误判成失败而触发回滚 +// +// 所以这里只承诺到"磁盘上的产物已就绪,pending 标记已写"。 +// 后续重启 → 新内核启动 → update 模块(第 0 层)确认或回滚。 +// +// 每一步失败都往回退:替换中途失败 ⇒ 立即用备份还原并清标记 +// (此时尚未重启,回滚在本进程内完成,机器始终可启动)。 +func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, error) { newBin := FindFile(root, "homed") if newBin == "" { - return fmt.Errorf("包内无 homed") + return nil, fmt.Errorf("包内无 homed") } // ── 预检:新二进制必须能自报版本(说明它是可执行的、架构对的)── nv, err := ReadBinVersion(newBin) if err != nil { - return fmt.Errorf("预检失败,新二进制不可执行: %w", err) + return nil, fmt.Errorf("预检失败,新二进制不可执行: %w", err) } publishProgress(bus, "kernel", "", "verify", "新内核可执行,版本 "+nv.Version) fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version) @@ -279,7 +284,7 @@ func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events. ts := NowStamp() backup := filepath.Join("/root", "homed-upgrade-backup-"+ts) if err := os.MkdirAll(backup, 0755); err != nil { - return fmt.Errorf("建备份目录: %w", err) + return nil, fmt.Errorf("建备份目录: %w", err) } targets := []string{binPath} if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); FileExists(dg) { @@ -287,7 +292,7 @@ func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events. } for _, t := range targets { if err := CopyFile(t, filepath.Join(backup, filepath.Base(t))); err != nil { - return fmt.Errorf("备份 %s: %w", t, err) + return nil, fmt.Errorf("备份 %s: %w", t, err) } } publishProgress(bus, "kernel", "", "backup", fmt.Sprintf("已备份 %d 个文件", len(targets))) @@ -297,9 +302,6 @@ func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events. // // 顺序很关键:先写标记再替换。若反过来(替换后才写),"替换成功但 // 没来得及写标记就崩了"会丢标记 ⇒ 下次启动不知道该回滚。 - // - // 标记由 update 模块(第 0 层)在下次启动时读;读到就说明上次升级 - // 留下的版本没能活到清除标记 ⇒ 回滚(见 internal/update/rollback.go)。 curVer := &BinVersion{} if v, verr := ReadBinVersion(binPath); verr == nil { curVer = v @@ -314,14 +316,15 @@ func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events. }); perr != nil { // 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力, // 而不带回滚的升级等于赌运气。 - return fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr) + return nil, fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr) } fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir)) } - rollback := func(cause error) error { + // restore 在替换失败时把已动的文件还原(尚未重启,机器仍可启动)。 + restore := func(cause error) (*Outcome, error) { publishProgress(bus, "kernel", "", "rollback", cause.Error()) - fmt.Printf("\n❌ %v\n== 开始回滚 ==\n", cause) + fmt.Printf("\n❌ %v\n== 就地还原(尚未重启,机器仍可启动)==\n", cause) if dataDir != "" { if cerr := ClearPendingUpgrade(dataDir); cerr != nil { fmt.Printf(" ⚠️ 清除升级确认标记失败: %v\n", cerr) @@ -330,74 +333,41 @@ func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events. 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) + 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) + publishResult(bus, "kernel", "", false, curVer.Version, nv.Version, true, cause.Error()) + return nil, 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)) + return restore(fmt.Errorf("替换 depgraph.bin: %w", err)) } fmt.Println("已替换 depgraph.bin") } if err := CopyFile(newBin, binPath); err != nil { - return rollback(fmt.Errorf("替换 homed: %w", err)) + return restore(fmt.Errorf("替换 homed: %w", err)) } fmt.Println("已替换 homed") + publishProgress(bus, "kernel", "", "ready", "新内核已就位,等待重启") - // ── 重启 + 后置验证 ── - 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)) - } - publishResult(bus, "kernel", "", true, curVer.Version, got.Version, false, "") - fmt.Printf("\n✅ 升级完成:%s (commit %s)\n", got.Version, ShortCommit(got.Commit)) - fmt.Printf(" 备份保留在 %s(确认稳定后可删)\n", backup) - return nil + return &Outcome{ + From: curVer, + To: nv, + BackupDir: backup, + }, 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 +// Outcome 是一次替换的结果(**不含重启**,重启由调用方走内核控制通道)。 +type Outcome struct { + From *BinVersion // 升级前版本(回滚目标) + To *BinVersion // 已就位的新版本 + BackupDir string // 备份目录(确认稳定后可删) } func CopyFile(src, dst string) error { @@ -437,9 +407,6 @@ func NowStamp() string { return time.Now().Format("20060102-150405") } -// sleepSecond 隔一秒(抽出来便于将来换成可注入的等待,测试不必真睡)。 -func sleepSecond() { time.Sleep(time.Second) } - // inferVersionFromRunning 在老内核不支持 --version 时,尽力推断当前版本。 // // 手段按可靠性排序,任一成功即返回: diff --git a/internal/update/source.go b/internal/update/source.go new file mode 100644 index 00000000..26631ab9 --- /dev/null +++ b/internal/update/source.go @@ -0,0 +1,101 @@ +package update + +// OTA 的**来源**解析:从 Release 自动更新,或指定本地安装包。 +// +// ## 为什么把"来源"做成一个显式类型 +// +// 两种来源的后半段完全一致(解包 → 预检 → 备份 → 替换 → pending), +// 只有"包从哪来"不同。若在各入口各写一遍 if release { 下载 } else { 用路径 }, +// 就会重演"三端三份实现慢慢漂移"的老问题。 +// +// 所以:**来源解析一次,升级流程只有一条**。 +// +// Source{Kind: FromRelease} → 查 GitHub Release → 下载 → 解包 +// Source{Kind: FromPackage, Path} → 直接用本地包 → 解包 +// ↓ +// Apply() 同一条路 + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// SourceKind 是 OTA 包的来源类别。 +type SourceKind string + +const ( + // FromPackage 表示"用户直接给了安装包路径"。 + // + // 为什么保留它:离线机器、内网部署、灰度验证都需要先自己拿到包再升级; + // 而且它是**可复现**的(同一个文件升同一台机器)。 + FromPackage SourceKind = "package" + + // FromRelease 表示"从仓库最新 GitHub Release 自动取包"。 + // + // 适用场景:常规运维升级,不想手工下载。 + // ★ 拿到的是**最新 Release**,不一定是用户以为的那个版本 —— + // 所以 Resolve 会把 release tag 一并回报,让调用方打印出来。 + FromRelease SourceKind = "release" +) + +// Source 是一次 OTA 的包来源。 +type Source struct { + Kind SourceKind + // Path 是本地安装包路径(仅 Kind=FromPackage 时有意义)。 + Path string +} + +// ParseSource 从参数解析来源(CLI/WebAPI 共用,避免各写一套判空逻辑)。 +// +// 规则: +// +// pkg 非空 → 本地包(**优先**:显式给的路径比"自动取最新"更明确) +// pkg 为空 + release → 从 Release 取 +// 两者都空 → 报错(不允许"猜一个默认动作") +func ParseSource(pkg string, release bool) (Source, error) { + pkg = strings.TrimSpace(pkg) + switch { + case pkg != "": + return Source{Kind: FromPackage, Path: pkg}, nil + case release: + return Source{Kind: FromRelease}, nil + default: + return Source{}, fmt.Errorf("必须指定安装包路径(pkg),或选择从 Release 自动更新(release)") + } +} + +// Resolve 把来源解析成"解包后的根目录",供 Apply 使用。 +// +// workDir 是调用方提供的临时目录(由调用方负责清理)。 +// 返回的 rel 仅在 Kind=FromRelease 时非 nil(携带用的是哪个 tag/资产)。 +func (s Source) Resolve(workDir string) (root string, rel *ReleaseInfo, err error) { + switch s.Kind { + case FromPackage: + if s.Path == "" { + return "", nil, fmt.Errorf("来源是本地包,但路径为空") + } + st, serr := os.Stat(s.Path) + if serr != nil { + return "", nil, fmt.Errorf("安装包不存在: %s(%v)", s.Path, serr) + } + if st.IsDir() { + return "", nil, fmt.Errorf("安装包是目录,不是文件: %s", s.Path) + } + root, err = ExtractPkg(s.Path, workDir) + return root, nil, err + + case FromRelease: + pkgPath := filepath.Join(workDir, "release.tar.gz") + info, derr := DownloadLatestRelease(pkgPath) + if derr != nil { + return "", info, derr + } + root, err = ExtractPkg(pkgPath, workDir) + return root, info, err + + default: + return "", nil, fmt.Errorf("未知的 OTA 来源: %q", s.Kind) + } +}