Files
HomeAgent/internal/update/executor.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

181 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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.

package update
// 内核侧的**升级执行者**:订阅 update_request 并真正执行升级。
//
// ## 为什么执行权必须在内核,而不是某个插件
//
// 升级会替换内核二进制并让进程消失。这个动作的执行者必须满足三条:
//
// ① 不会被用户禁用(插件可以 auto_start:false / disable)
// ② 不依赖插件进程活着(插件崩了请求就丢了)
// ③ 知道自己的 dataDir / binPath(内核才有 ks.DataDir 这个权威值)
//
// 只有内核模块同时满足。而 pluginmgr 作为插件,三条全不满足。
//
// ## 为什么是「延迟挂载」而不是在 Init 里订阅
//
// update 模块必须在**第 0 层**(回滚判定要早于一切模块),但事件总线
// 由 kernelapi 层产出 —— 第 0 层时它还不存在。
//
// 所以分两段:
//
// Init(第 0 层) : 回滚判定 + 能力探测(不需要总线)
// Attach(总线就绪后): 订阅 update_request,接管执行
//
// Attach 由调用方(cmd/homed)在 kernelapi 层完成后调用。它幂等:
// 重复调用不会重复订阅。
import (
"fmt"
"log"
"os"
"sync"
"github.com/JianFeeeee/HologramAgent/internal/events"
)
// executor 是内核侧的升级执行器实例(进程级单例)。
type executor struct {
mu sync.Mutex
attached bool
unsub func()
// running 防止并发升级:两个请求同时进来会互相覆盖备份目录、
// 交错替换 homed 与 depgraph。(旧实现完全没有互斥。)
running bool
}
var kernelExec = &executor{}
// AttachExecutor 挂载内核侧升级执行器(幂等)。
//
// bus 为空时什么也不做(未接总线的宿主,如部分单测)。
// 返回的 cleanup 用于关停时退订。
func AttachExecutor(bus *events.Bus, dataDir string) func() {
if bus == nil {
return func() {}
}
kernelExec.mu.Lock()
if kernelExec.attached {
kernelExec.mu.Unlock()
return func() {}
}
kernelExec.attached = true
kernelExec.unsub = bus.Subscribe(events.EventUpdateRequest, func(evt *events.Event) {
handleUpdateRequest(evt, dataDir, bus)
})
kernelExec.mu.Unlock()
log.Printf("[update] kernel-side upgrade executor attached (data_dir=%s)", dataDir)
return func() {
kernelExec.mu.Lock()
defer kernelExec.mu.Unlock()
if kernelExec.unsub != nil {
kernelExec.unsub()
kernelExec.unsub = nil
}
kernelExec.attached = false
}
}
// handleUpdateRequest 处理一条 update_request。
//
// ## 语义分工
//
// scope=kernel → **本模块**执行(内核升级是内核的职责)
// scope=plugin → 交给插件通道(pluginmgr 自己订阅处理)
//
// 内核侧只接管 kernel,不越界去管插件 —— 插件升级要用 hmapdev 现场编译,
// 那是应用域的事。
//
// ## 为什么异步
//
// 升级会替换二进制并请求重启,本进程随后消失。事件处理器是**同步投递**的
// (events.Bus.Publish 直接调 handler),在处理器里同步做完会让发布方卡住
// 直到进程被杀。
func handleUpdateRequest(evt *events.Event, dataDir string, bus *events.Bus) {
if evt == nil || evt.Payload == nil {
return
}
scope, _ := evt.Payload["scope"].(string)
if scope != "kernel" {
return // 插件升级不归内核执行器
}
pkgPath, _ := evt.Payload["pkg"].(string)
release, _ := evt.Payload["release"].(bool)
kernelExec.mu.Lock()
if kernelExec.running {
kernelExec.mu.Unlock()
log.Printf("[update] 拒绝并发升级请求(来源 %s):已有升级在进行中", evt.Source)
publishResult(bus, "kernel", "", false, "", "", false,
"已有升级正在进行中,本次请求被拒绝(避免备份与替换交错)")
return
}
kernelExec.running = true
kernelExec.mu.Unlock()
go func() {
defer func() {
kernelExec.mu.Lock()
kernelExec.running = false
kernelExec.mu.Unlock()
}()
if err := RunUpgrade(pkgPath, release, dataDir, bus); err != nil {
log.Printf("[update] upgrade failed (source=%s): %v", evt.Source, err)
}
}()
}
// RunUpgrade 执行一次内核升级(内核侧入口,含形态分派)。
//
// 流程:解析来源 → 形态闸门 → 取包 → Apply(写 pending + 原子替换)。
// **不重启**:重启由调用方走 KernelControlAPI(restartCh → exit 42)。
func RunUpgrade(pkgPath string, release bool, dataDir string, bus *events.Bus) error {
src, err := ParseSource(pkgPath, release)
if err != nil {
publishResult(bus, "kernel", "", false, "", "", false, err.Error())
return err
}
bus = eventBusOr(bus)
binPath := KernelBinaryPath()
// ★ 形态闸门:先判断能不能自己升,再决定要不要下载 880MB。
capab := Detect(binPath, dataDir)
plan := PlanUpgrade(capab, pkgPath)
if !plan.Executable {
publishResult(bus, "kernel", "", false, "", "", false, plan.Reason)
return &NotExecutableError{Plan: plan}
}
workDir, err := os.MkdirTemp("", "kernel-upgrade-*")
if err != nil {
publishResult(bus, "kernel", "", false, "", "", false, err.Error())
return fmt.Errorf("建临时目录: %w", err)
}
defer os.RemoveAll(workDir)
root, _, err := src.Resolve(workDir)
if err != nil {
publishResult(bus, "kernel", "", false, "", "", false, err.Error())
return err
}
newDG := FindFile(root, "depgraph.bin")
_, err = Apply(root, binPath, newDG, dataDir, bus)
return err
}
// NotExecutableError 表示本机形态不允许自替换(调用方应转成可执行提示)。
type NotExecutableError struct {
Plan UpgradePlan
}
func (e *NotExecutableError) Error() string {
if len(e.Plan.Commands) > 0 {
return e.Plan.Reason + ";请执行: " + e.Plan.Commands[0]
}
return e.Plan.Reason
}