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

549 lines
20 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
// 安装形态与升级能力**探测**。
//
// ## 为什么必须有这一层
//
// 同一次升级,在不同安装形态下的正确做法完全不同:
//
// deb/rpm → 文件归包管理器所有。直接替换会让 dpkg/rpm 数据库与实际文件
// 不一致:下次 `apt upgrade` 会把改动覆盖回去,或者报
// "本地文件被修改"并拒绝升级。**必须交给包管理器**。
// tar → 没有包管理器,自己替换是正确做法。
// container→ 容器文件系统是镜像的一部分,重启即还原。**升级必须重拉镜像**。
//
// 在这层存在之前,`Apply` 只有一条路(直接替换二进制),于是在 deb 形态下
// 它要么因权限失败、要么(以 root 运行时)悄悄把包管理器数据库搞脏。
//
// ## 为什么不是"猜路径"
//
// 判据全部来自**可观测的系统事实**(dpkg 的 info 目录、rpm 数据库、
// 容器标记文件),而不是"二进制在 /usr/bin 所以大概是 deb"这类推断。
// 推断错了的后果是选错升级通道 —— 而这类错误在生产上极难反查。
import (
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/systemd"
)
// DefaultServiceUnit 是内核的服务单元名。
//
// ★ 从 internal/systemd 取,**不在本包重复定义**:单元名曾经在两处各写一份,
// 改名时只改了一处(systemd.UnitName="hologramagent.service"),而
// cmd/homed-upgrade 仍硬编码 "homeagent" —— 升级成功的机器重启了错误的服务。
const DefaultServiceUnit = systemd.UnitName
// InstallKind 是本机的安装形态。
type InstallKind string
const (
// KindDeb 表示由 dpkg 安装(.deb)。
KindDeb InstallKind = "deb"
// KindRPM 表示由 rpm 安装(.rpm)。
KindRPM InstallKind = "rpm"
// KindTar 表示解包安装(无包管理器记录)。
KindTar InstallKind = "tar"
// KindContainer 表示运行在容器里(升级必须重拉镜像)。
KindContainer InstallKind = "container"
// KindSource 表示从源码直接运行(dev 环境)。
KindSource InstallKind = "source"
// KindUnknown 表示探测不出来。
//
// ★ 它的语义是「不知道」,**不是**「没有包管理器所以大概是 tar」。
// 调用方拿到 Unknown 必须拒绝自动升级,而不是退回一条猜的路径。
KindUnknown InstallKind = "unknown"
)
// Variant 是发行包变体(决定包里有哪些文件)。
type Variant string
const (
VariantFull Variant = "full" // homed + waiter + GUI + 模型 + ORT
VariantServer Variant = "server" // homed + waiter + 模型 + ORT
VariantClient Variant = "client" // waiter + GUI(**没有 homed**)
VariantToolchain Variant = "toolchain" // homed + waiter + hmapdev + SDK + 模型
VariantUnknown Variant = "unknown"
)
// HasKernel 报告该变体是否包含内核二进制。
//
// client 包没有 homed ⇒ 它的「升级」不涉及内核,任何内核 OTA 对它都不适用。
func (v Variant) HasKernel() bool {
return v != VariantClient && v != VariantUnknown
}
// Capabilities 是本机升级能力的快照。**所有升级决策的唯一输入**。
type Capabilities struct {
Kind InstallKind `json:"kind"`
Variant Variant `json:"variant"`
BinPath string `json:"bin_path"`
DataDir string `json:"data_dir"`
DepGraph string `json:"depgraph,omitempty"`
PackageName string `json:"package_name,omitempty"` // deb/rpm 的包名
ServiceUnit string `json:"service_unit,omitempty"`
// CanWriteBin 报告当前进程能否**直接替换**二进制。
//
// ★ 这是"能不能自己动手"的唯一判据。为 false 时绝不能尝试替换:
// 失败会在替换中途发生(例如 depgraph 已换、homed 没换),
// 留下一个不一致的安装。
CanWriteBin bool `json:"can_write_bin"`
// Reason 说明为什么当前形态不能自替换(可直接呈现给用户)。
Reason string `json:"reason,omitempty"`
}
// UpgradeChannel 是本机应当走的升级通道。
type UpgradeChannel string
const (
// ChannelPackageManager 表示升级必须由包管理器执行(deb/rpm)。
ChannelPackageManager UpgradeChannel = "package-manager"
// ChannelSelfReplace 表示可以自替换(tar 且目录可写)。
ChannelSelfReplace UpgradeChannel = "self-replace"
// ChannelContainerImage 表示必须重拉镜像。
ChannelContainerImage UpgradeChannel = "container-image"
// ChannelUnsupported 表示无法自动升级(需人工介入)。
ChannelUnsupported UpgradeChannel = "unsupported"
)
// Channel 决定本机该走哪条升级通道。
//
// ## 为什么 deb/rpm 一律走包管理器(即使用户有 root)
//
// 「能写文件」不等于「该写文件」。dpkg 记录了每个受管文件的校验和,
// 绕过它替换的后果:
//
// · 下次 apt upgrade 覆盖回旧版(升级"消失")
// · 或 dpkg 检测到本地修改,**拒绝**后续升级(机器被卡住)
// · 两种都必须人工介入才能恢复
func (c Capabilities) Channel() UpgradeChannel {
switch c.Kind {
case KindContainer:
return ChannelContainerImage
case KindDeb, KindRPM:
return ChannelPackageManager
case KindTar:
if c.CanWriteBin {
return ChannelSelfReplace
}
return ChannelUnsupported
default:
return ChannelUnsupported
}
}
// UpgradeCommand 返回本机**应当执行**的升级命令(给人看的)。
//
// 空串表示“没有可建议的命令”(container/source/unknown)。
func (c Capabilities) UpgradeCommand(target string) string {
switch c.Channel() {
case ChannelPackageManager:
pkg := c.PackageName
if pkg == "" {
pkg = "hologramagent"
}
if c.Kind == KindRPM {
return "sudo dnf upgrade " + pkg
}
if target != "" {
return "sudo apt install --only-upgrade " + pkg + " # 或: sudo dpkg -i " + target
}
return "sudo apt install --only-upgrade " + pkg
case ChannelSelfReplace:
return "homed-upgrade -apply # 本机安装形态允许自替换"
case ChannelUnsupported:
// ★ tar 但目录不可写(最常见的本机形态)也必须给命令。
//
// 此时 Channel() 返回 Unsupported(因为不能自替换),但**升级
// 本身是可行的** —— 只需提权。返回空串会让用户看到“无法升级”
// 却不知道下一步做什么,而实际上一行 sudo 就解决了。
//
// 判据:形态是 tar 且唯一障碍是权限。
if c.Kind == KindTar && strings.Contains(c.Reason, "没有写权限") {
return "sudo homed-upgrade -apply # 需要提权:服务以非 root 运行,动不了 " + filepath.Dir(c.BinPath)
}
return ""
default:
return ""
}
}
// Detect 探测本机安装形态与能力。
//
// binPath 为空时取当前进程的内核二进制路径。**这个兜底只适用于本进程
// 就是 homed 的情形**(kernelmod 启动路径);外部工具(homed-upgrade)
// 必须自己解析并显式传入,否则会把“自己”当成内核。
//
// ★ 实测踩到:CLI 同目录没有 homed 时,resolveBinPath 曾兜底到
//
// os.Executable() ⇒ “升级 homed”实际变成“把 CLI 覆盖成新包里的 homed”,
// 一个 1 字节的假包就能把工具本体换成一段 shell 脚本。
// 现在 resolveBinPath 找不到就返回空,而 Apply 对空 binPath 直接拒绝。
//
// 任何一步探测失败都不会 panic:返回已确定的字段 + Unknown/空值。
func Detect(binPath, dataDir string) Capabilities {
if binPath == "" {
binPath = KernelBinaryPath()
}
c := Capabilities{
BinPath: binPath,
DataDir: dataDir,
ServiceUnit: DefaultServiceUnit,
Kind: KindUnknown,
Variant: VariantUnknown,
}
if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); FileExists(dg) {
c.DepGraph = dg
}
// 容器优先判:容器里也可能装了 deb(基础镜像带来的),
// 但那时替换文件毫无意义 —— 重启容器就回到镜像原样。
if inContainer() {
c.Kind = KindContainer
c.Reason = "运行在容器内:镜像文件系统只读且重启即还原,升级应重拉镜像"
} else {
c.Kind, c.PackageName = detectPackageKind(binPath)
}
c.Variant = detectVariant(binPath)
c.CanWriteBin, c.Reason = canReplaceBinary(c.Kind, binPath)
return c
}
// inContainer 判断是否运行在容器里。
//
// 用容器运行时留下的标准标记(Docker 的 /.dockerenv、Podman/CRI-O 的
// /run/.containerenv)。**不用** /proc/1/cgroup 里的 "docker" 字样:
// systemd-nspawn、WSL2、以及某些嵌套 cgroup 环境会误判。
func inContainer() bool {
for _, p := range []string{"/.dockerenv", "/run/.containerenv"} {
if _, err := os.Stat(p); err == nil {
return true
}
}
return false
}
// detectPackageKind 判断二进制是否归包管理器所有,并返回包名。
//
// 判据是**文件属主记录**,不是路径:
// - dpkg 把每个包的文件清单写在 /var/lib/dpkg/info/<pkg>.list
// - rpm 有 rpm 命令可查
//
// 因此 deb 包装在 /opt 也能被认出来,tar 包解到 /usr/bin 也不会被误认。
func detectPackageKind(binPath string) (InstallKind, string) {
if name := dpkgOwner(binPath); name != "" {
return KindDeb, name
}
if name := rpmOwner(binPath); name != "" {
return KindRPM, name
}
// 无包管理器记录:区分“源码编译产物”与“tar 解包安装”。
//
// ★ 只认 go build 的临时目录形态(/go-build<N>/),**不能用** "/tmp/"。
// 那条太宽了:用户把 tar 包解到 /tmp 试装、或 CI 在 /tmp 里跑
// 集成测,都会被误判成“源码运行”而拒绝升级。判据必须指向
// **构建行为本身**,而不是“文件放在哪”。
if isGoBuildTempPath(binPath) {
return KindSource, ""
}
return KindTar, ""
}
// isGoBuildTempPath 判断路径是否是 `go run`/`go test` 的临时产物。
//
// 形态:<tmpdir>/go-build<digits>/b001/exe/<name>
// 只匹配这个结构,不匹配“放在 tmp 下”。
func isGoBuildTempPath(p string) bool {
i := strings.Index(p, "/go-build")
if i < 0 {
return false
}
// go-build 后面必须紧跟数字(go 的实际命名),否则可能是用户自建的目录。
rest := p[i+len("/go-build"):]
j := 0
for j < len(rest) && rest[j] >= '0' && rest[j] <= '9' {
j++
}
return j > 0 && j < len(rest) && rest[j] == '/'
}
// dpkgOwner 在 dpkg 数据库里查 binPath 属于哪个包。
//
// 直接扫 info 目录里以本仓包名前缀开头的 .list,比 exec dpkg -S 更快
// 且不依赖 dpkg 可执行文件(容器里常被精简掉)。
func dpkgOwner(binPath string) string {
abs, err := filepath.Abs(binPath)
if err != nil {
return ""
}
entries, err := os.ReadDir("/var/lib/dpkg/info")
if err != nil {
return ""
}
for _, e := range entries {
name := e.Name()
if !strings.HasSuffix(name, ".list") {
continue
}
pkg := strings.TrimSuffix(name, ".list")
// 只看本仓的包:避免为了查一个文件而读几百个包的清单
if !strings.HasPrefix(pkg, "hologramagent") && !strings.HasPrefix(pkg, "homeagent") {
continue
}
b, err := os.ReadFile(filepath.Join("/var/lib/dpkg/info", name))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
if strings.TrimSpace(line) == abs {
return pkg
}
}
}
return ""
}
// rpmOwner 用 rpm 查文件属主(rpm 数据库格式不稳定,交给官方命令)。
func rpmOwner(binPath string) string {
out, err := runQuiet("rpm", "-qf", binPath)
if err != nil {
return ""
}
out = strings.TrimSpace(out)
// rpm 未命中时输出 "file ... is not owned by any package" 且退出码非 0,
// 走到这里说明命中了。
if out == "" || strings.Contains(out, "not owned") {
return ""
}
if i := strings.IndexByte(out, '\n'); i > 0 {
out = out[:i]
}
return strings.TrimSpace(out)
}
// detectVariant 从安装布局推断变体。
//
// 判据用**文件存在性**(包内容),不用包名:包名里的变体后缀
// 在迁移/改名后未必可靠,而"有没有 GUI / hmapdev / 模型"是事实。
func detectVariant(binPath string) Variant {
const (
modelDir = "/usr/lib/hologram/models"
guiDir = "/usr/lib/hologram-gui"
)
hasKernel := FileExists(binPath)
hasModel := dirExists(modelDir)
hasGUI := dirExists(guiDir)
hasToolchain := FileExists("/usr/bin/hmapdev")
switch {
case !hasKernel:
// 没有 homed:只有 client 包是这样
if FileExists("/usr/bin/waiter") || hasGUI {
return VariantClient
}
return VariantUnknown
case hasToolchain && hasModel:
return VariantToolchain
case hasGUI && hasModel:
return VariantFull
case hasModel:
return VariantServer
case hasKernel:
// 有内核但无模型:老包或最小安装
return VariantServer
default:
return VariantUnknown
}
}
// canReplaceBinary 判定能否直接替换二进制,并给出不可行的原因。
//
// 四个条件全部满足才允许:
// 1. 不是包管理器拥有的文件(否则会破坏数据库一致性)
// 2. 不在容器里
// 3. 不是从源码直接运行(那是 dev 环境,升级无意义)
// 4. 当前进程对**目标目录**有写权限(rename 需要目录可写,不是文件可写)
func canReplaceBinary(kind InstallKind, binPath string) (bool, string) {
switch kind {
case KindDeb:
return false, "由 dpkg 安装:直接替换会让包数据库与实际文件不一致,必须用 apt/dpkg 升级"
case KindRPM:
return false, "由 rpm 安装:直接替换会让包数据库与实际文件不一致,必须用 dnf/rpm 升级"
case KindContainer:
return false, "运行在容器内:文件系统随容器销毁还原,必须重拉镜像"
case KindSource:
// ★ 必须显式拒绝:源码运行环境的“升级”是重新构建,不是换磁盘上的文件。
// 旧版本的 Channel() 对 source 返回 unsupported,而 CanWriteBin 却因为
// 目录可写而为 true —— 两个判据互相矛盾,调用方会拿到“能写但不能升”。
return false, "从源码直接运行:升级方式是重新构建,而不是替换二进制"
case KindUnknown:
return false, "无法确定安装形态:拒绝自动替换(可能破坏包管理器状态)"
}
dir := filepath.Dir(binPath)
if !dirWritable(dir) {
return false, "当前用户对 " + dir + " 没有写权限(服务以非 root 用户运行),需提权或改用包管理器"
}
return true, ""
}
// dirWritable 报告能否在 dir 里创建/改名文件。
//
// ★ 必须测**目录**而不是文件:原子替换用的是 rename,它要求的是
//
// 目录写权限。文件本身可写但目录不可写时,rename 依然失败。
func dirWritable(dir string) bool {
probe := filepath.Join(dir, ".ha-write-probe")
f, err := os.OpenFile(probe, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return false
}
f.Close()
os.Remove(probe)
return true
}
func dirExists(p string) bool {
st, err := os.Stat(p)
return err == nil && st.IsDir()
}
// runQuiet 执行一个只读查询命令,返回 stdout(失败返回 error,由调用方忽略)。
//
// 单独抽出来是为了让探测代码不直接依赖 os/exec:探测必须**永不 panic、
// 永不阻塞**,而 exec 的失败模式(命令不存在、权限、超时)都该收敛在一处。
func runQuiet(name string, args ...string) (string, error) {
out, err := exec.Command(name, args...).Output()
return string(out), err
}
// SystemdActive 报告某个 systemd 单元是否正在运行。
//
// 用途只有一个:CLI 升级完成后要告诉用户“接下来会发生什么”——
// 服务在跑就说明必须走热重启通道;没跑就说明下次启动即生效。
//
// ★ 不用它做“要不要自替换”的判据:那由 Capabilities.CanWriteBin 决定,
//
// 两件事没有关系(服务没跑也照样可能没有写权限)。
func SystemdActive(unit string) bool {
if unit == "" {
return false
}
out, err := runQuiet("systemctl", "is-active", unit)
return err == nil && strings.TrimSpace(out) == "active"
}
// DataDirFromConfigDB 只读地取 config.db 里的 core.daemon.data_dir。
//
// ## 为什么要单独一个入口(而不是让调用方自己开库)
//
// data_dir 就在 config.db 里,而 config.db 就在 data_dir 里 —— 这是个
// 循环依赖,所以“找到一个候选 config.db”永远是猜的。但这不是问题:
// **猜的是线索,不是结论**。读出来的值是内核自己写下的,不是我们的推测。
//
// ## ★ 为什么复用 internal/config 而不是自己 sql.Open
//
// 旧实现自己 `sql.Open("sqlite3", ...)` + `PRAGMA query_only=1`。问题是
// cmd/homed-upgrade 用 `CGO_ENABLED=0` 打包,而 go-sqlite3 在无 cgo 时是
// **stub**:连接能开、查询必然失败并返回 "requires cgo to work" ——
// 错误被吞掉、函数返回空串 ⇒ 发行包里“自动发现 dataDir”这条路径
// **从未真正工作过**,而且症状是“无法确定数据目录”,指着完全错误的方向。
//
// 复用 ConfigRegistry 的好处:同一套打开/读取语义、随仓库一起演进,
// 且它本来就处理“库不存在/表不存在”的情形(返回默认值)。
func DataDirFromConfigDB(dir string) string {
p := filepath.Join(dir, "config.db")
if !FileExists(p) {
return ""
}
reg := config.NewConfigRegistry(p)
defer reg.Close()
return strings.TrimSpace(reg.GetString("core.daemon.data_dir", ""))
}
// ── 内核数据目录的**权威源** ─────────────────────────────────────
//
// ## 为什么要有这两个函数
//
// pending 标记、备份目录、坏二进制留档全落在 dataDir 下。而"内核实际用的是
// 哪个 dataDir"只有一个可靠来源:内核启动时的 `-data`(ks.DataDir)。
//
// 历史教训(三类都踩过):
//
// ① 从 exe 路径推导 → /usr/bin/data,而生产是 /var/lib/hologram
// ⇒ 备份目录建在只读目录(EROFS),升级必挂
// ② 硬编码生产路径 → 目录/用户迁移后指向不存在的目录
// ⇒ 标记落到内核读不到的地方 ⇒ **升级失败静默不回滚**
// ③ 读 HOMEAGENT_DATA_DIR → 部署方可能不设,设了也未必与 -data 一致
//
// 所以:**不猜**。内核模块在启动时注入真值,其余调用方读它;
// 取不到就返回空串,由 Apply 的空值守卫拒绝升级。
//
// 「不知道」伪装成「知道」比没有守卫更危险:守卫看起来在守,
// 实际守了个永远非空的常量。
var (
kernelDataDirMu sync.RWMutex
kernelDataDir string
)
// SetKernelDataDir 由内核适配层(internal/kernelmod/update)在启动时注入
// 真实的 `-data` 值。**不是给普通调用方用的**。
func SetKernelDataDir(dir string) {
kernelDataDirMu.Lock()
defer kernelDataDirMu.Unlock()
kernelDataDir = dir
}
// KernelDataDir 返回内核实际使用的 dataDir(取不到时为空串)。
func KernelDataDir() string {
kernelDataDirMu.RLock()
defer kernelDataDirMu.RUnlock()
return kernelDataDir
}
// ── 事件总线的**内核侧注入** ──────────────────────────────────
var (
eventBusMu sync.RWMutex
eventBus *events.Bus
)
// SetEventBus 由内核适配层在启动时注入事件总线。
//
// ## ★ 为什么改成注入,而不是让调用方每次传进来
//
// `Apply` 需要发进度/结果事件。此前签名收 `*events.Bus`,于是**插件**必须
// 拿到总线才能触发升级 —— 而插件拿总线正是"安全监控可被绕过"的根源:
// 一旦把 *Bus 交给插件,它就能 bus.Publish 伪造任意事件。
//
// 改成内核注入后:
//
// · 插件调用 Apply 不用再碰总线(传 nil 即可)
// · 进度事件由**内核子系统**以内核身份发出(Source="update"),语义也正确
// · 插件侧再没有理由持有 *events.Bus
func SetEventBus(b *events.Bus) {
eventBusMu.Lock()
defer eventBusMu.Unlock()
eventBus = b
}
// eventBusOr 返回传入的总线;为空时退回内核注入的那个。
func eventBusOr(b *events.Bus) *events.Bus {
if b != nil {
return b
}
eventBusMu.RLock()
defer eventBusMu.RUnlock()
return eventBus
}