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

124 lines
5.3 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 模块的适配层:把 internal/update 的能力接进内核模块体系。
//
// ## 为什么分两层(与仓库其他模块同一惯例)
//
// internal/update/ 业务实现(版本比较、解包、替换、回滚、Release 查询)
// internal/kernelmod/update/ 适配层(读 ks → 调业务包 → 写回 ks)
//
// 参照 supervisor 模块:实现在 internal/supervisor,这里只做装配。
// 分开的理由:internal/update 要被 CLI / WebAPI / agent 工具三端调用,
// 它们都没有 KernelState;而模块体系只有内核启动一条路径。
//
// ## 它为什么必须在第 0 层
//
// 肩负「启动失败回滚上一次升级」的职责(详见 internal/update/rollback.go)。
// 该职责必须早于所有其他模块:若排在后面,升级后新内核在 memory/llm 阶段
// 崩了,而回滚还没跑 ⇒ 机器进崩溃循环,只能靠 guard 的 failback 兜底。
// failback 是最后手段,不该承担"换错二进制"这种已知可回滚的故障。
package update
import (
"log"
"path/filepath"
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
upd "github.com/JianFeeeee/HologramAgent/internal/update"
)
// FacilityVersion 是本模块提供的设施名。
//
// 语义:启动时的版本自检与回滚判定**已完成**。需要按版本行事的模块
// (healthcheck 上报版本、pluginhost 判插件兼容性)应 Requires 它,
// 以确保读到的是回滚后的最终版本,而不是升级前的那个。
const FacilityVersion = "kernel.version"
// Module 实现 kernelboot.Module。
type Module struct{}
func (Module) Name() string { return "update" }
func (Module) Requires() []string { return nil } // 第 0 层:不依赖任何人
func (Module) Provides() []string { return []string{FacilityVersion} }
// Init 执行版本自检,必要时回滚上一次升级。
func (Module) Init(ks *kernelboot.KernelState) error {
// ⓪ 把内核**真实**的 dataDir 注入 update 包。
//
// 必须最先做:pending 标记/备份/回滚验全都靠它。进程内调用方
// (pluginmgr / webui)用 upd.KernelDataDir() 取值,而不是自己推导。
//
// 推导必错(实测三类):从 exe 推 → /usr/bin/data(只读,备份必挂);
// 硬编码 → 迁移后指向不存在的目录;读 env → 未必与 -data 一致。
upd.SetKernelDataDir(ks.DataDir)
st := &upd.State{}
ks.Update = st
bin := upd.KernelBinaryPath()
st.BinaryPath = bin
// ① 自报版本(升级是否真换成功,只有二进制自己能答)
v, err := upd.ReadBinVersion(bin)
if err != nil {
// 读不出来不等于致命:可能是极老版本(无 --version)。
// 没有证据表明升级失败 ⇒ 不回滚,记录后继续。
log.Printf("[update] kernel version probe failed: %v", err)
st.ProbeErr = err
} else {
st.Version, st.Commit, st.SDKVersion = v.Version, v.Commit, v.SDKVersion
log.Printf("[update] kernel %s (commit %s, sdk %s)", v.Version, upd.ShortCommit(v.Commit), v.SDKVersion)
}
// ② 上一次升级的回滚判定
rolled, from, to, rerr := upd.RollbackIfUnconfirmed(ks.DataDir, bin)
if rerr != nil {
// 回滚失败必须响亮:继续装配等于用一个已知起不来的版本
return rerr
}
if rolled {
log.Printf("[update] ROLLED BACK kernel %s → %s(上一次升级未能确认启动)", to, from)
st.RolledBack = true
st.RolledFrom, st.RolledTo = from, to
// 回滚后重读版本:后续模块要看到**回滚后**的版本
if v2, err2 := upd.ReadBinVersion(bin); err2 == nil {
st.Version, st.Commit, st.SDKVersion = v2.Version, v2.Commit, v2.SDKVersion
}
} else {
// ③ 版本已确认能启动 ⇒ 清除待确认标记
//
// 只依赖"进程活着且执行到第 0 层",不依赖其他模块 ⇒ 不会误判。
if err := upd.ClearPendingUpgrade(ks.DataDir); err != nil {
// 清不掉不阻断启动:后果只是下次启动再判一次(幂等)
log.Printf("[update] clear pending marker: %v", err)
}
}
// ④ 依赖图路径(真正的 digest 校验在 kernelboot 装载期,那里失败会拒绝启动)
if dg := filepath.Join(filepath.Dir(bin), "depgraph.bin"); upd.FileExists(dg) {
st.DepGraphPath = dg
}
// ⑤ 安装形态与升级能力**探测**(不改任何状态,只产出可读快照)。
//
// 为何放在第 0 层:它就是第 0 层自己的事实——“我能怎么升”。
// 而且它要读 dpkg/rpm 数据库与目录权限,这些在启动时只跑一次。
//
// ★ 探测失败不能阻断启动:它只是诊断信息。拿不到就报 Unknown,
// Apply 会因 Unknown 拒绝自替换(失败方向是收紧)。
capab := upd.Detect(bin, ks.DataDir)
ks.UpdateCapabilities = &capab
log.Printf("[update] install kind=%s variant=%s channel=%s can_replace=%v",
capab.Kind, capab.Variant, capab.Channel(), capab.CanWriteBin)
if !capab.CanWriteBin {
// 不阻断启动,但必须响亮:否则用户以为“点一下就能升级”。
log.Printf("[update] 本机不支持自替换升级:%s", capab.Reason)
}
log.Printf("[update] subsystem ready (version=%s rolled_back=%v)", st.Version, st.RolledBack)
return nil
}
func init() { kernelmod.Register(Module{}) }
var _ kernelboot.Module = Module{}