Files
HomeAgent/internal/update/events.go
JianFeeeee ecde8dd515 fix(update): 补上内核升级缺失的四道安全闸(H1–H4)
四条缺陷的共同点都是「静默产出一个看起来成功的升级」,而失败的表现
是机器进崩溃循环且没人知道原因。

H1 dataDir 为空必须拒绝升级
  Apply 此前只在 dataDir 非空时写 pending,空值仅跳过标记、后照常替换
  二进制 ⇒ 产出「无回滚能力」的升级。现改为在动任何文件之前直接失败。
  另新增导出 update.ResolveDataDir(env → <exe>/data 兜底)。

H2 备份目录不能写 /root
  backup 原为 /root/homed-upgrade-backup-<ts>,服务在 ProtectSystem=strict
  + ReadWritePaths=/var/lib/hologram 下运行,非 root 对 /root 无写权限
  ⇒ MkdirAll 必 EACCES ⇒ 首次升级必挂。改为落在 dataDir 下。
  rollback 的 homed-broken-<ts> 同样从(通常只读的)二进制目录挪到 dataDir。

H3 备份目录要有 GC
  旧注释写「确认稳定后可删」,但全仓无任何删除逻辑。新增
  PruneOldBackups:保留最近 3 个 homed-upgrade-backup-* 与最近 1 个
  homed-broken-*,其余 RemoveAll,在 Apply 成功返回前调用。
  只处理名字精确为「前缀 + NowStamp 形态」的目录,不误删 pending 标记
  或前缀相近的第三方目录。

H4 结果事件吞错误 + 成功不发 result
  publishResult 里 `if errMsg != "" { _ = errMsg }` 把失败原因丢弃,payload
  也没有 error 字段 ⇒ 订阅方只看到 ok=false。现在 error 字段恒定存在。
  Apply 成功路径此前从不发 result,现补发 ok=true。

测试:kernel_apply_test.go 新增 5 个用例(含题目要求的四个函数名)。
2026-10-28 17:20:35 +08:00

92 lines
2.9 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
// 更新进度/结果的事件发布。
//
// ## 为什么走 EventBus 而不是"日志 + WebUI 轮询"
//
// 升级是**长耗时且会让进程消失**的动作。轮询在重启瞬间必然断线,
// 而断线那一刻用户最需要知道"升级到哪一步/失败了/已回滚"。
// 事件总线是内核既有的发布订阅机制(同步投递、handler panic 隔离),
// 用它不需要新造一条通知通道。
//
// ## 为什么必须"先发事件再动作"
//
// 升级尾部几步(replace → restart)会让进程消失。顺序一反,
// 最后几步的事件永远发不出去。所以每个 publish* 都紧贴在其描述的
// 动作**之前**调用。
import (
"fmt"
"github.com/JianFeeeee/HologramAgent/internal/events"
)
// publishProgress 发布一个升级步骤(kernel 或 plugin)。
//
// scope: "kernel" | "plugin"; name: 插件名(kernel 时为空)
// step: verify / backup / replace / restart / verify_version / rollback / done / failed
func publishProgress(bus *events.Bus, scope, name, step, detail string) {
if bus == nil {
return
}
bus.Publish(&events.Event{
Type: events.EventUpdateProgress,
Source: "update",
Payload: map[string]interface{}{
"scope": scope,
"name": name,
"step": step,
"detail": detail,
},
})
}
// publishResult 发布升级最终结果(成功或失败,含回滚轨迹)。
//
// ★ error 字段**总是**出现在 payload 里(成功时为空串)。
//
// 此前失败路径把 errMsg 收进一个 `if errMsg != "" { _ = errMsg }` 分支
// 直接丢掉了 —— 于是订阅方只能看到 ok=false,**看不到失败原因**,
// 而"为什么失败"正是用户唯一想知道的。字段固定存在(即使为空)还让
// 订阅方不必做"字段有没有"的兼容分支。
//
// ★ 顺序约定:调用方必须在动作**之前**发事件(见文件头注释)。
// 成功路径也一样:result 发出后调用方可能立即重启,进程随即消失。
func publishResult(bus *events.Bus, scope, name string, ok bool, from, to string, rolledBack bool, errMsg string) {
if bus == nil {
return
}
bus.Publish(&events.Event{
Type: events.EventUpdateResult,
Source: "update",
Payload: map[string]interface{}{
"scope": scope,
"name": name,
"ok": ok,
"from": from,
"to": to,
"rolled_back": rolledBack,
"error": errMsg,
},
})
}
// publishCheck 发布一次更新检查结果(只读,不改动任何东西)。
func publishCheck(bus *events.Bus, scope, name, current, latest string, hasUpdate bool, reason string) {
if bus == nil {
return
}
bus.Publish(&events.Event{
Type: events.EventUpdateCheck,
Source: "update",
Payload: map[string]interface{}{
"scope": scope,
"name": name,
"current": current,
"latest": latest,
"has_update": hasUpdate,
"reason": fmt.Sprintf("%v", reason),
},
})
}