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

126 lines
4.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
// OTA 的**来源**解析:从 Release 自动更新,或指定本地安装包。
//
// ## 为什么把"来源"做成一个显式类型
//
// 两种来源的后半段完全一致(解包 → 预检 → 备份 → 替换 → pending),
// 只有"包从哪来"不同。若在各入口各写一遍 if release { 下载 } else { 用路径 },
// 就会重演"三端三份实现慢慢漂移"的老问题。
//
// 所以:**来源解析一次,升级流程只有一条**。
//
// Source{Kind: FromRelease} → 查 GitHub Release → 下载 → 解包
// Source{Kind: FromPackage, Path} → 直接用本地包 → 解包
// ↓
// Apply() 同一条路
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// SourceKind 是 OTA 包的来源类别。
type SourceKind string
const (
// FromPackage 表示"用户直接给了安装包路径"。
//
// 为什么保留它:离线机器、内网部署、灰度验证都需要先自己拿到包再升级;
// 而且它是**可复现**的(同一个文件升同一台机器)。
FromPackage SourceKind = "package"
// FromRelease 表示"从仓库最新 GitHub Release 自动取包"。
//
// 适用场景:常规运维升级,不想手工下载。
// ★ 拿到的是**最新 Release**,不一定是用户以为的那个版本 ——
// 所以 Resolve 会把 release tag 一并回报,让调用方打印出来。
FromRelease SourceKind = "release"
)
// Source 是一次 OTA 的包来源。
type Source struct {
Kind SourceKind
// Path 是本地安装包路径(仅 Kind=FromPackage 时有意义)。
Path string
}
// ParseSource 从参数解析来源(CLI/WebAPI 共用,避免各写一套判空逻辑)。
//
// 规则:
//
// pkg 非空 → 本地包(**优先**:显式给的路径比"自动取最新"更明确)
// pkg 为空 + release → 从 Release 取
// 两者都空 → 报错(不允许"猜一个默认动作")
func ParseSource(pkg string, release bool) (Source, error) {
pkg = strings.TrimSpace(pkg)
switch {
case pkg != "":
return Source{Kind: FromPackage, Path: pkg}, nil
case release:
return Source{Kind: FromRelease}, nil
default:
return Source{}, fmt.Errorf("必须指定安装包路径(pkg),或选择从 Release 自动更新(release)")
}
}
// Resolve 把来源解析成"解包后的根目录",供 Apply 使用。
//
// workDir 是调用方提供的临时目录(由调用方负责清理)。
// 返回的 rel 仅在 Kind=FromRelease 时非 nil(携带用的是哪个 tag/资产)。
func (s Source) Resolve(workDir string) (root string, rel *ReleaseInfo, err error) {
switch s.Kind {
case FromPackage:
if s.Path == "" {
return "", nil, fmt.Errorf("来源是本地包,但路径为空")
}
st, serr := os.Stat(s.Path)
if serr != nil {
return "", nil, fmt.Errorf("安装包不存在: %s(%v)", s.Path, serr)
}
if st.IsDir() {
return "", nil, fmt.Errorf("安装包是目录,不是文件: %s", s.Path)
}
root, err = ExtractPkg(s.Path, workDir)
return root, nil, err
case FromRelease:
// ★ 先试差分(第 4 项):只需下载几十 KB 的 delta,用本地旧二进制
// 重建新二进制,不用下 88MB 整包。
//
// 差分是**优化**,不是可用性前提:任何一步不可用就退回整包。
// 实测量级:改一行逻辑只差 80 字节,1MB 文件小改的补丁占 0.8%。
//
// 为何先取 info 再决定:delta 是否可用取决于**当前本机 commit**
// 能否在 release 资产里找到匹配的 `-from-<commit>.delta`。
// 为何传 current 而不是 nil:delta 的匹配需要**本机 commit**
// (命名里的 `-from-<commit8>`)。传 nil 会让 CurrentC 为空,
// 于是永远匹配不到 delta —— 那会让整条差分路径静默失效。
preInfo := CheckRelease(currentVersionForDelta())
if preInfo.DeltaURL != "" {
if ok, derr := TryDeltaUpgrade(workDir, KernelBinaryPath(), preInfo.Latest, preInfo); ok {
root := filepath.Join(workDir)
if FindFile(root, "homed") != "" {
return root, preInfo, nil
}
} else if derr != nil {
// 不当作失败:记录后走整包。不向上抛,避免“差分失败→升级失败”。
preInfo.Reason = "差分不可用,退回整包:" + derr.Error()
}
}
pkgPath := filepath.Join(workDir, "release.tar.gz")
info, derr := DownloadLatestRelease(pkgPath)
if derr != nil {
return "", info, derr
}
root, err = ExtractPkg(pkgPath, workDir)
return root, info, err
default:
return "", nil, fmt.Errorf("未知的 OTA 来源: %q", s.Kind)
}
}