Files
HomeAgent/cmd/homed-upgrade/main.go
JianFeeeee ba281a855a feat(update): 差分 OTA + 安装形态分派 + 事件总线统一受安全监控
三条主线,由 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 失败。
2026-10-29 09:54:55 +08:00

359 lines
15 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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