mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
三条主线,由 e2e 实测驱动。 ## 一、差分 OTA(internal/update/delta.go) 实测:改一行逻辑,88MB 的二进制逐字节只差 80 字节,而 OTA 一直下整包。 用 rsync 族的滚动哈希块匹配(4KB 粒度),不是 bsdiff —— 后者需 17×N 峰值内存(88MB → 1.5GB),而升级恰可能发生在内存紧张时。 真实二进制实测:88,036,728 字节 → 补丁 77,316 字节(0.088%)。 三道闸:应用前验旧文件 sha256(错基线会静默产出垃圾)、应用后验结果 sha256、COPY/LITERAL 越界防护。任何失败**退回整包**,不向上抛 —— 差分是优化,不是可用性前提。 接入:Source.Resolve 优先试 delta;CheckRelease 按 -from-<commit8>.delta 匹配(不猜基线);CLI 新增 -make-delta;打包脚本 build_delta(需 PREVIOUS_HOMED),delta 进 SHA256SUMS。 ## 二、安装形态分派(internal/update/install.go + channel.go) 内核对「能不能自己升级」此前只有一个答案:直接替换二进制。而 deb/rpm 形态下那样做会让 dpkg/rpm 数据库与实际内容不一致(下次 apt upgrade 覆盖回旧版,或 dpkg 拒绝后续升级)。 新增探测:deb/rpm/tar/container/source 五类形态 + 变体(full/server/ client/toolchain)+ 目录写权限。判据全部来自可观测系统事实(dpkg 的 info 目录、容器标记文件),不做路径推断。 三端统一走 PlanUpgrade 分派;形态闸门放在**下载 880MB 之前**。 ## 三、事件总线统一受安全监控(internal/events + security) 用户指正:「eventbus 上所有事件都要接受安全模块的监控」。 此前 EventPolicy 只被 internal/sdk/events.go 调用,而那是四条路径中的 一条。s.Publish / s.Subscribe / s.EventBus() 拿的是裸 *events.Bus, 任何插件都能绕过策略发任意事件(含伪造内核事件)。 现在闸门在 Bus.Publish/Subscribe 本体上(Guard 接口注入,避免 events ↔ security 成环),四条路径全部覆盖。sdk.EventBus() 改返回带身份的 BusHandle。内核身份免授权,但载荷照样过凭据闸门。 白名单按主体收窄:agent_output/stage/terminal_output 只允许承载者 (webui/cli/agentcli)发布 —— 它们是内核向外的输出面,谁都能发就等于 能伪造 agent 说的话。 ## e2e 暴露并修掉的三个真实回归 [bus] subscribe denied: pluginmgr type=update_request [bus] subscribe denied: skillmgr type=skill_detected 前两个让升级请求静默丢失、职责链交接断掉。内部协作事件必须可订阅 (安全边界在发布侧,订阅只是「听得到」)。 ## 顺带的止血修复 - 回滚判据从 `--version` 改为 digest:旧内核(1.3.x 及以前)没有该 flag,而它恰是最常见的回滚目标 —— 原逻辑让「从旧版升级失败」永久 无法回滚。 - CopyFile 补 fsync(替换 homed 本体时掉电会留半截文件)。 - restore 完整还原才清 marker(原先先清再还原,还原失败就失去第二次 自愈机会)。 - 回滚时移除升级中新增的 depgraph(否则「旧 homed + 新 depgraph」 会因 digest 不匹配崩溃循环)。 - homed-upgrade 不再硬编码 systemctl restart(丢 guard 状态、unit 名 漂移);改用内核已有的 restartCh → exit 42 语义。 - homed-upgrade 不再用 CGO_ENABLED=0 编却依赖 go-sqlite3(stub 下 静默失效,该路径从未工作过);改用 internal/config。 - 服务单元补 User=hologramagent + 三条安装路径补建用户(此前发行物里 完全没有落实降权)。 - rpm 的 fpm 调用补 --after-install(此前 rpm 装完根本不建用户)。 ## 判据 新增 30 条:差分 8(往返/压缩比/错基线/截断/篡改/边界)、通道 8、 执行器 6、事件闸门 8、按主体授权 8。全仓 go test ./... 0 失败。
359 lines
15 KiB
Go
359 lines
15 KiB
Go
// Command homed-upgrade 是**内核**升级闭环的 CLI 入口(默认只探测,不写盘)。
|
||
//
|
||
// 实现全部在 internal/update —— 内核侧的 kernel_update 工具走同一套
|
||
// 代码,避免"CLI 能升级、agent 调用却把机器搞坏"这类两份实现的漂移。
|
||
//
|
||
// ## 范围:只面向内核
|
||
//
|
||
// 只处理 homed / depgraph.bin。**不碰插件**——插件有独立的升级通道
|
||
// (pluginmgr),且与内核的兼容性由"自动禁用不兼容插件"兜底。
|
||
//
|
||
// ## 为什么默认只探测
|
||
//
|
||
// 替换二进制不可逆,且内核是这个机器上唯一常驻的关键服务。默认只做
|
||
// 探测与预检、把"会发生什么"完整报出来;`-apply` 才真动手。
|
||
//
|
||
// ## ★ 它**不重启**服务
|
||
//
|
||
// 这一条原先做错了:CLI 曾直接 `systemctl restart hologramagent`,而
|
||
// internal/update/kernel.go 的 Apply 文档专门论证过为什么不能这么做:
|
||
//
|
||
// · 杀掉整个 cgroup(含 guard)—— 把监督者一起杀掉
|
||
// · 丢失 exit 42 语义:guard 的 failures / failbackRound 状态全丢
|
||
// · 发命令的进程正是被重启杀掉的进程 ⇒ 返回值不可靠
|
||
//
|
||
// 正确的重启决策在 guard 手里(只有它知道这次退出该不该重拉)。CLI 是
|
||
// 外部管理命令,它的职责到"磁盘上的产物已就绪、pending 已写"为止;
|
||
// 重启由**运行中的内核**经 KernelControlAPI 发起(restartCh → exit 42)。
|
||
//
|
||
// 若服务没在跑,连重启都不需要:下次启动自然加载新二进制并由第 0 层
|
||
// update 模块确认或回滚。
|
||
package main
|
||
|
||
import (
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/JianFeeeee/HologramAgent/internal/update"
|
||
)
|
||
|
||
func fail(format string, a ...interface{}) {
|
||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||
os.Exit(1)
|
||
}
|
||
|
||
func main() {
|
||
pkgPath := flag.String("pkg", "", "本地安装包(.tar.gz);与 -release 二选一")
|
||
release := flag.Bool("release", false, "从 GitHub 最新 Release 下载 OTA 包(默认仅探测;配合 -apply 执行)")
|
||
binPath := flag.String("bin", "", "当前运行的内核二进制路径(默认取本进程自身;它不是 homed 时必须显式给)")
|
||
pluginsDir := flag.String("plugins", "", "外部插件目录(探测兼容性用;为空则跳过该步)")
|
||
dataDirFlag := flag.String("data", "", "内核数据目录(= 内核的 --data;不填则从 config.db 的 core.daemon.data_dir 读)")
|
||
apply := flag.Bool("apply", false, "真正执行升级(默认只探测+预检)")
|
||
|
||
// ★ 发布侧差分生成(不参与升级流程)。
|
||
//
|
||
// 为何放在这个 CLI 而不是单开一个命令:它只是把两个文件喂给
|
||
// internal/update.MakeDeltaForRelease,而那个包已经在这里了。
|
||
// 多发一个二进制意味着多一条要维护的发布链。
|
||
makeDelta := flag.Bool("make-delta", false, "生成差分补丁(发布用):-delta-old <旧homed> 与 -delta-new <新homed>")
|
||
deltaOld := flag.String("delta-old", "", "旧版本 homed 路径(上一版发布的二进制)")
|
||
deltaNew := flag.String("delta-new", "", "新版本 homed 路径")
|
||
deltaOut := flag.String("delta-out", "kernel.delta", "补丁输出路径")
|
||
flag.Parse()
|
||
|
||
if *makeDelta {
|
||
if *deltaOld == "" || *deltaNew == "" {
|
||
fmt.Fprintln(os.Stderr, "-make-delta 需要 -delta-old <旧homed> 与 -delta-new <新homed>")
|
||
os.Exit(2)
|
||
}
|
||
hdr, size, err := update.MakeDeltaForRelease(*deltaOld, *deltaNew, *deltaOut)
|
||
if err != nil {
|
||
fail("生成差分: %v", err)
|
||
}
|
||
st := hdr.Stats(size)
|
||
fmt.Printf("补丁已写出: %s\n", *deltaOut)
|
||
fmt.Printf(" 旧大小 %d 字节\n 新大小 %d 字节\n 补丁 %d 字节(%.3f%%)\n",
|
||
st.OldSize, st.NewSize, st.PatchSize, st.Ratio*100)
|
||
fmt.Printf(" 基线导入: %s\n", update.ShortSum(hdr.OldSHA256))
|
||
fmt.Printf(" 目标导入: %s\n", update.ShortSum(hdr.NewSHA256))
|
||
fmt.Printf("\n发布资产名建议: %s\n",
|
||
update.DeltaAssetName("<version>", "linux", "amd64", update.ShortCommit(hdr.OldSHA256)))
|
||
return
|
||
}
|
||
|
||
if *pkgPath == "" && !*release {
|
||
fmt.Fprintln(os.Stderr, "必须提供 -pkg <安装包.tar.gz> 或 -release")
|
||
os.Exit(2)
|
||
}
|
||
|
||
// ── 0. 探测:安装形态决定后面每一步该怎么做 ──
|
||
//
|
||
// 必须在**动任何文件之前**做:deb/rpm 形态下直接替换会破坏包管理器
|
||
// 数据库(下次 apt upgrade 覆盖回旧版,或 dpkg 拒绝后续升级)。
|
||
bin := resolveBinPath(*binPath)
|
||
if bin == "" {
|
||
// ★ 拒绝,而不是猜。同目录没有 homed 时曾经兜底到 os.Executable(),
|
||
// 于是“升级 homed”实际变成“把 CLI 自己覆盖成新包里的 homed”。
|
||
fmt.Fprintln(os.Stderr, "找不到内核二进制。")
|
||
fmt.Fprintln(os.Stderr, "本工具默认升级**与自己同目录**的 homed;找不到时请显式指定:")
|
||
fmt.Fprintln(os.Stderr, " homed-upgrade -bin /usr/bin/homed ...")
|
||
fmt.Fprintln(os.Stderr, "提示:`systemctl show hologramagent -p ExecStart` 里能看到真实路径。")
|
||
os.Exit(2)
|
||
}
|
||
dataDir := resolveDataDir(*dataDirFlag)
|
||
capab := update.Detect(bin, dataDir)
|
||
printCapabilities(capab)
|
||
|
||
if *apply {
|
||
if ch := capab.Channel(); ch != update.ChannelSelfReplace {
|
||
// ★ 不是"不支持升级",是"升级必须由别人执行"。
|
||
// 区分这两件事很重要:前者听起来像缺陷,后者是可执行的操作指引。
|
||
fmt.Printf("\n== 本机不能自替换 ==\n%s\n", capab.Reason)
|
||
if cmd := capab.UpgradeCommand(""); cmd != "" {
|
||
fmt.Printf("\n请执行:\n %s\n", cmd)
|
||
}
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
if dataDir == "" {
|
||
fmt.Println("\n⚠️ 无法确定内核数据目录:升级确认标记与备份无处可放(无回滚能力)")
|
||
fmt.Println(" 请显式指定 -data <dir>(与内核 unit 的 --data 一致)")
|
||
if *apply {
|
||
os.Exit(1)
|
||
}
|
||
fmt.Println(" (探测模式继续,-apply 会被拒绝)")
|
||
}
|
||
|
||
tmp, err := os.MkdirTemp("", "homed-upgrade-*")
|
||
if err != nil {
|
||
fail("建临时目录: %v", err)
|
||
}
|
||
defer os.RemoveAll(tmp)
|
||
|
||
if *release {
|
||
path := filepath.Join(tmp, "release.tar.gz")
|
||
info, downloadErr := update.DownloadLatestRelease(path)
|
||
if downloadErr != nil {
|
||
fail("下载最新 GitHub Release: %v", downloadErr)
|
||
}
|
||
*pkgPath = path
|
||
fmt.Printf("最新 Release: %s (%s)\n", info.Latest, info.Asset)
|
||
}
|
||
|
||
fmt.Printf("\n== 解包 %s ==\n", *pkgPath)
|
||
root, err := update.ExtractPkg(*pkgPath, tmp)
|
||
if err != nil {
|
||
fail("解包: %v", err)
|
||
}
|
||
|
||
// ── 1. 当前版本 ──
|
||
cur, curErr := update.ReadBinVersion(bin)
|
||
if curErr != nil {
|
||
fmt.Printf("⚠️ 读当前内核版本失败: %v\n", curErr)
|
||
cur = nil
|
||
} else {
|
||
fmt.Printf("当前内核: %s (commit %s)\n", cur.Version, update.ShortCommit(cur.Commit))
|
||
}
|
||
|
||
// ── 2. 新版本 ──
|
||
newBin := update.FindFile(root, "homed")
|
||
if newBin == "" {
|
||
fail("包内找不到 homed")
|
||
}
|
||
nv, err := update.ReadBinVersion(newBin)
|
||
if err != nil {
|
||
fail("新内核无法自报版本(可能不是本仓构建的二进制): %v", err)
|
||
}
|
||
fmt.Printf("包内内核: %s (commit %s)\n", nv.Version, update.ShortCommit(nv.Commit))
|
||
|
||
// ── 3. 版本比较 ──
|
||
fmt.Printf("\n== 结论 ==\n%s\n", update.CompareVersions(cur, nv))
|
||
|
||
// ── 4. depgraph 必须与新二进制同批 ──
|
||
newDG := update.FindFile(root, "depgraph.bin")
|
||
if newDG == "" {
|
||
fmt.Println("⚠️ 包内缺 depgraph.bin —— 内核启动会因依赖图不匹配拒绝启动")
|
||
} else {
|
||
fmt.Printf("depgraph.bin: 包内已带(%d 字节),将与 homed 同批替换\n", update.FileSize(newDG))
|
||
}
|
||
|
||
// ── 5. 插件兼容性预判 ──
|
||
//
|
||
// ★ 目录为空时**跳过并明说**,不再打印"无需重编"。
|
||
// 此前那个函数读 plugin.json 的 sdk_version 字段,而该字段根本
|
||
// 不存在(生产 32 个插件 grep 命中 0)⇒ 恒返回空 ⇒ 永远打印
|
||
// "现有插件与新内核 SDK 主版本一致"。那是假话,比不检查更糟。
|
||
reportPluginCompat(*pluginsDir, nv.SDKVersion)
|
||
|
||
if !*apply {
|
||
fmt.Println("\n(探测模式:未改动任何文件。确认无误后加 -apply)")
|
||
return
|
||
}
|
||
|
||
// ── 6. 真正升级:预检 → 备份 → pending → 原子替换(**不含重启**)──
|
||
out, err := update.Apply(root, bin, newDG, dataDir, nil)
|
||
if err != nil {
|
||
fail("升级失败: %v", err)
|
||
}
|
||
fmt.Printf("\n新内核已就位:%s → %s\n备份保留在 %s\n", out.From.Version, out.To.Version, out.BackupDir)
|
||
|
||
// ── 7. 不再自己重启 ──
|
||
//
|
||
// 说清楚接下来会发生什么,而不是替用户拍一个可能破坏 guard 状态的决定。
|
||
fmt.Printf("\n== 下一步 ==\n")
|
||
if update.SystemdActive(update.DefaultServiceUnit) {
|
||
fmt.Printf("内核正在运行,请让它走热重启通道(不丢 guard 状态):\n")
|
||
fmt.Printf(" · 重启会由内核自己发起(restartCh → exit 42 → guard 用新二进制重拉)\n")
|
||
fmt.Printf(" · 若需手工触发,用 WebUI 的「重启」按钮或 POST /api/v1/kernel/restart\n")
|
||
fmt.Printf("✅ 升级产物已就绪:%s → %s\n", out.From.Version, out.To.Version)
|
||
} else {
|
||
fmt.Printf("服务当前未运行:直接下次启动即生效(pending 标记会确认或回滚)。\n")
|
||
fmt.Printf("✅ 升级产物已就绪:%s → %s\n", out.From.Version, out.To.Version)
|
||
}
|
||
fmt.Printf("\n启动后若新内核起不来,第 0 层 update 模块会自动回滚到 %s。\n", out.From.Version)
|
||
}
|
||
|
||
// resolveBinPath 决定“要升级的那个二进制”在哪。找不到时返回**空串**。
|
||
//
|
||
// ## ★ 为什么不兜底到 os.Executable()
|
||
//
|
||
// 这个兜底是真的会搞坏东西的(实测踩到):
|
||
//
|
||
// 本 CLI 叫 homed-upgrade,与 homed **不同名**。如果同目录没有 homed
|
||
// (比如开发机、或把 CLI 单独拷到一个空目录里跑),旧兜底会返回
|
||
// **CLI 自己的路径**,于是“升级 homed”实际变成“把 CLI 覆盖成新包里的
|
||
// homed” —— 一个 1 字节的假包就能把工具本体换成一段 shell 脚本。
|
||
//
|
||
// 静默做错比直接报错危险得多,所以:找不到就返回空,由调用方拒绝。
|
||
//
|
||
// 取值顺序:
|
||
//
|
||
// ① -bin(人工显式给,最可靠)
|
||
// ② 本 CLI 同目录的 homed(发行包里两者总是同批安装)
|
||
func resolveBinPath(explicit string) string {
|
||
if d := strings.TrimSpace(explicit); d != "" {
|
||
return d
|
||
}
|
||
if exe, err := os.Executable(); err == nil {
|
||
if resolved, err2 := filepath.EvalSymlinks(exe); err2 == nil {
|
||
exe = resolved
|
||
}
|
||
cand := filepath.Join(filepath.Dir(exe), "homed")
|
||
if update.FileExists(cand) {
|
||
return cand
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// resolveDataDir 返回内核**真实**的数据目录(升级标记与备份的落点)。
|
||
//
|
||
// ## ★ 不猜路径,只认两个显式来源
|
||
//
|
||
// 1. `-data <dir>`(人工直接给,最高优先)
|
||
// 2. config.db 里的 `core.daemon.data_dir`
|
||
//
|
||
// 都拿不到就返回**空串**,由调用方拒绝 -apply 并说明怎么修。
|
||
//
|
||
// ## 为什么不再自己开 sqlite
|
||
//
|
||
// 旧实现用 `sql.Open("sqlite3", ...)` 只读查配置。问题是打包脚本用
|
||
// `CGO_ENABLED=0` 编这个 CLI,而 go-sqlite3 在无 cgo 时是 **stub**:
|
||
// 查询返回 "requires cgo to work",错误被吞掉、返回空串 ⇒ 发行包里
|
||
// "自动发现 dataDir"这条路径**从未真正工作过**。
|
||
//
|
||
// 改为复用内核已有的 internal/config.ConfigRegistry:同一个打开逻辑、
|
||
// 同一套 key 语义,不需要 CGO 时也能编(它在无配置时返回默认值)。
|
||
func resolveDataDir(explicit string) string {
|
||
if d := strings.TrimSpace(explicit); d != "" {
|
||
return d
|
||
}
|
||
// 与内核一致的候选顺序:安装形态优先。
|
||
bin, err := os.Executable()
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
if resolved, err2 := filepath.EvalSymlinks(bin); err2 == nil {
|
||
bin = resolved
|
||
}
|
||
candidates := []string{
|
||
filepath.Join(filepath.Dir(filepath.Dir(bin)), "var/lib/hologram"),
|
||
"/var/lib/hologram",
|
||
filepath.Join(filepath.Dir(filepath.Dir(bin)), "newqqagent"),
|
||
"/home/hologramagent",
|
||
"/home/newqqagent",
|
||
}
|
||
for _, d := range candidates {
|
||
if !update.FileExists(filepath.Join(d, "config.db")) {
|
||
continue
|
||
}
|
||
if v := update.DataDirFromConfigDB(d); v != "" {
|
||
fmt.Printf("数据目录来源: %s/config.db 的 core.daemon.data_dir = %s\n", d, v)
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func printCapabilities(c update.Capabilities) {
|
||
fmt.Printf("== 本机安装形态 ==\n")
|
||
fmt.Printf(" 形态: %s\n", c.Kind)
|
||
fmt.Printf(" 变体: %s\n", c.Variant)
|
||
fmt.Printf(" 二进制: %s\n", c.BinPath)
|
||
if c.DepGraph != "" {
|
||
fmt.Printf(" 依赖图: %s\n", c.DepGraph)
|
||
}
|
||
if c.PackageName != "" {
|
||
fmt.Printf(" 包名: %s\n", c.PackageName)
|
||
}
|
||
fmt.Printf(" 可自替换: %v\n", c.CanWriteBin)
|
||
if !c.CanWriteBin && c.Reason != "" {
|
||
fmt.Printf(" 原因: %s\n", c.Reason)
|
||
}
|
||
fmt.Printf(" 升级通道: %s\n", c.Channel())
|
||
}
|
||
|
||
// reportPluginCompat 报告插件与新内核的兼容性预判。
|
||
//
|
||
// ★ 无数据时**明确说无数据**,不打印"无需重编"。
|
||
//
|
||
// 旧实现的判据是 plugin.json 的 sdk_version 字段,而内核 manifest 结构里
|
||
// 根本没有这个字段(生产 32 个插件 grep 命中 0)⇒ ProbePluginCompat 恒
|
||
// 返回空 ⇒ CLI 永远打印"现有插件与新内核 SDK 主版本一致,无需重编"。
|
||
//
|
||
// 用户读着这句话点 -apply,升完发现一半插件被自动禁用 —— 这不是"没实现",
|
||
// 是"实现了但在撒谎"。真实数据在握手时才有(proc/process.go 的 res.SDKVersion),
|
||
// 静态预判拿不到,那就如实说拿不到。
|
||
func reportPluginCompat(dir, wantSDK string) {
|
||
if dir == "" {
|
||
fmt.Println("\n== 插件兼容性 ==\n未指定 -plugins,跳过预判(升级后内核会自动禁用不兼容的插件并写明原因)")
|
||
return
|
||
}
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
fmt.Printf("\n== 插件兼容性 ==\n无法读取 %s(%v):跳过预判\n", dir, err)
|
||
return
|
||
}
|
||
compat := update.ProbePluginCompat(dir, wantSDK)
|
||
if len(compat) > 0 {
|
||
fmt.Printf("\n== 插件兼容性警告(%d 个)==\n", len(compat))
|
||
fmt.Println("换内核后这些插件会握手失败并被**自动禁用**(写入禁用配置 + 原因):")
|
||
for _, p := range compat {
|
||
fmt.Printf(" · %-16s SDK %s(新内核要求主版本 %s)\n", p.Name, p.SDKVersion, p.WantMajor)
|
||
}
|
||
fmt.Println("它们在界面上会显示为「已禁用」并带原因,重编插件或手动启用即可恢复。")
|
||
return
|
||
}
|
||
// 没有可判定的数据时,说清楚原因,而不是给一个假的"全部兼容"。
|
||
fmt.Printf("\n== 插件兼容性 ==\n共 %d 个插件目录,但**无法静态预判**兼容性:\n", len(entries))
|
||
fmt.Println(" plugin.json 不含 sdk_version 字段(该字段不存在),真实 SDK 版本只在握手时上报。")
|
||
fmt.Println(" ⇒ 升级后内核会逐个握手,主版本不符的会被自动禁用并在界面上写明原因。")
|
||
fmt.Println(" ⇒ 本题结论为「未检查」,**不是**「全部兼容」。")
|
||
}
|
||
|
||
var _ = time.Now
|