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

129 lines
4.5 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
// 差分级别的**发包/取包**:把「整包下载」换成「delta + 本地基线重建」。
//
// ## 为什么需要这一层(实测)
//
// 一次逻辑改动,88MB 的二进制只差 80 字节;而 OTA 下的是整个 tar.gz。
// 差分把这一步从 88MB 降到几十 KB 量级。
//
// ## 为什么 delta 必须绑定旧文件的 sha256
//
// 差分只能对**特定旧版本**应用。用错基线会静默产出一份"看起来正常"
// 的垃圾 —— 大小对、结构对,就是内容错。所以:
//
// ① 构建期在 delta 里写入旧/新两者完整 sha256
// ② 取包时先验本地基线 sha256,不符就**放弃差分,退回整包**
// ③ 应用后验结果 sha256,不符同样退回整包
//
// 回退而不是报错,是这个设计的要点:差分是**优化**,不是可用性前提。
import (
"fmt"
"os"
"path/filepath"
)
// DeltaAssetName 是发布时 delta 资产的命名约定。
//
// 形如:hologramagent-1.4.0-linux-amd64-from-<oldCommit8>.delta
// 带上 from-commit 是为了让下载方一眼看出它对应哪个基线,
// 而不是"随便挑一个 delta 试试"。
func DeltaAssetName(version, goos, goarch, fromCommit string) string {
return fmt.Sprintf("hologramagent-%s-%s-%s-from-%s.delta", version, goos, goarch, ShortCommit(fromCommit))
}
// TryDeltaUpgrade 尝试用 delta 重建新二进制到 outDir。
//
// 返回值:
//
// true, nil —— 成功重建(outDir/homed 已就位)
// false, nil —— 没有可用 delta 或不适用(调用方应退回整包)
// false, err —— 出错(同样建议退回整包,但错误应记录)
//
// ## 什么情况下退回整包
//
// · 本地没有基线二进制(首次安装 / 二进制已被替换)
// · 发布里没有匹配当前版本的 delta
// · 基线 sha256 不符(本地版本不是 delta 针对的那个)
// · 应用后校验失败
//
// 这四种都**不是**致命错误:整包永远可用,差分只是省流量。
func TryDeltaUpgrade(pkgDir, binPath, version string, rel *ReleaseInfo) (bool, error) {
if rel == nil || rel.DeltaURL == "" {
return false, nil // 该 release 没提供 delta
}
if !FileExists(binPath) {
return false, nil
}
// ① 读本地基线(要用它的字节重建)
base, err := os.ReadFile(binPath)
if err != nil {
return false, fmt.Errorf("读本地基线: %w", err)
}
// ② 下载 delta
patchPath := filepath.Join(pkgDir, "kernel.delta")
if err := downloadFile(rel.DeltaURL, patchPath); err != nil {
return false, fmt.Errorf("下载 delta: %w", err)
}
patch, err := os.ReadFile(patchPath)
if err != nil {
return false, fmt.Errorf("读 delta: %w", err)
}
// ③ 应用(内含基线校验 + 结果校验)
out, err := ApplyDelta(base, patch)
if err != nil {
// ★ 退回整包,不当失败:可能是本地版本与 delta 基线不符
return false, err
}
// ④ 落位
dst := filepath.Join(pkgDir, "bin", "homed")
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return false, err
}
if err := WriteFileAtomic(dst, out, 0755); err != nil {
return false, err
}
return true, nil
}
// MakeDeltaForRelease 生成发布用的 delta(构建脚本 / 发布流程调用)。
//
// oldBin 是**上一个已发布版本**的二进制,newBin 是本版。
func MakeDeltaForRelease(oldBin, newBin, outPath string) (*DeltaHeader, int64, error) {
hdr, size, err := MakeDeltaFile(oldBin, newBin, outPath)
if err != nil {
return nil, 0, err
}
st := hdr.Stats(size)
// 只在**确实变小**时才值得发:偶尔(重构、换编译器)delta 可能
// 比整包还大,那时发布方应直接不发 delta。
if st.Ratio > 0.5 {
return hdr, size, fmt.Errorf(
"delta 过大(%.1f%% of %d bytes):本版不适合差分发布,请只发整包",
st.Ratio*100, hdr.NewSize)
}
return hdr, size, nil
}
// currentVersionForDelta 读本机内核版本,读不到时返回 nil。
//
// ★ 为什么必须传真实版本给 CheckRelease(而不是 nil):
// delta 的资产名里带 `-from-<commit8>`,而那个 commit 来自 CurrentC。
// 传 nil 会让 CurrentC 为空 ⇒ 永远匹配不到 delta ⇒ 整条差分路径静默失效
// (看起来"差分已上线",实际每次都在下整包)。
//
// 读不到版本时返回 nil 是合理的降级:老内核没有 --version,
// 那种情况下也确实不该尝试差分(连基线身份都无法确认)。
func currentVersionForDelta() *BinVersion {
v, err := ReadBinVersion(KernelBinaryPath())
if err != nil {
return nil
}
return v
}