mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-03 07:47:45 +00:00
三条主线,由 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 失败。
142 lines
5.2 KiB
Go
142 lines
5.2 KiB
Go
package update
|
||
|
||
// 备份目录的垃圾回收。
|
||
//
|
||
// ## 为什么必须有 GC
|
||
//
|
||
// 每次 Apply 都在 dataDir 下留一个 homed-upgrade-backup-<ts>(homed +
|
||
// depgraph.bin,通常几十 MB),每次启动时回滚坏二进制还会多一个
|
||
// homed-broken-<ts>。旧代码里 Outcome.BackupDir 的注释写着
|
||
// "确认稳定后可删",但**全仓没有一行删除逻辑** —— 注释承诺了一个
|
||
// 不存在的机制,而这类注释比没注释更糟:读代码的人会以为已经处理了。
|
||
//
|
||
// 不删的后果不是"占点磁盘"这么温和:dataDir 和 pending 标记在同一个
|
||
// 文件系统上,磁盘被备份吃满之后 pending 标记就写不进去 ⇒ Apply 拒绝
|
||
// 升级(守卫是对的,但保护频繁触发本身就是故障)。
|
||
//
|
||
// ## 保留策略:upgrade 备 3 个,broken 备 1 个
|
||
//
|
||
// 为什么不对称:
|
||
//
|
||
// · homed-upgrade-backup-* 是**回滚源**,可能被任意一次"上次升级的
|
||
// pending 标记"引用。多留几个才能覆盖"升级→重启→再升级"的连环操作。
|
||
// · homed-broken-* 是**事后现场**,只有人工排查时才看,且总是看最近
|
||
// 一次。留 1 个(而非 0 个)是为了不丢现场。
|
||
//
|
||
// 排序用目录名而不是 mtime:名字里的 ts 是 NowStamp() 的
|
||
// "20060102-150405"(定长、字典序 == 时间序),不依赖文件系统时间戳的
|
||
// 精度和时区,也不会被一次 cp -a 打乱。
|
||
|
||
import (
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
const (
|
||
// BackupDirPrefix 是升级前备份目录名的前缀,需与 Apply 中拼接的一致。
|
||
BackupDirPrefix = "homed-upgrade-backup-"
|
||
// BrokenDirPrefix 是回滚时留存的"坏二进制"目录名前缀。
|
||
BrokenDirPrefix = "homed-broken-"
|
||
|
||
// keepUpgradeBackups 保留的升级备份个数(回滚源,需要覆盖连环升级)。
|
||
keepUpgradeBackups = 3
|
||
// keepBrokenDirs 保留的坏二进制现场个数(只用于事后排查)。
|
||
keepBrokenDirs = 1
|
||
)
|
||
|
||
// BrokenDirPath 返回某个时间戳的坏二进制留档目录(位于 dataDir 下)。
|
||
//
|
||
// ★ 为什么不放在二进制目录旁:二进制目录通常是 /usr/local/bin,
|
||
// 在 ProtectSystem=strict 下只读,写不进去就等于**丢掉现场**。
|
||
func BrokenDirPath(dataDir, ts string) string {
|
||
return filepath.Join(dataDir, BrokenDirPrefix+ts)
|
||
}
|
||
|
||
// PruneOldBackups 清理 dataDir 下过量的备份目录。
|
||
//
|
||
// 保留最近 keepUpgradeBackups 个 homed-upgrade-backup-* 与最近
|
||
// keepBrokenDirs 个 homed-broken-*,其余 RemoveAll。
|
||
//
|
||
// 设计上的克制(这几条都是为了"GC 本身不能造成故障"):
|
||
//
|
||
// - dataDir 为空 ⇒ 直接返回。空值曾被当作"当前目录",在这类函数里
|
||
// 那就是灾难(会去删 CWD 下匹配前缀的东西)。
|
||
// - 任何一次 RemoveAll 失败都只 log,不返回 error —— 调用点在 Apply
|
||
// 成功路径的末尾,GC 失败绝不能让一次**已经成功的升级**变成失败。
|
||
// - 只处理目录、只处理名字精确匹配 "<前缀><ts>" 形态的项(ts 必须是
|
||
// NowStamp 的形状,见 isBackupStamp)—— 不递归、不碰 pending 标记、
|
||
// 不碰名字碰巧带同样前缀的其它东西。
|
||
func PruneOldBackups(dataDir string) {
|
||
if dataDir == "" {
|
||
return
|
||
}
|
||
pruneByPrefix(dataDir, BackupDirPrefix, keepUpgradeBackups)
|
||
pruneByPrefix(dataDir, BrokenDirPrefix, keepBrokenDirs)
|
||
}
|
||
|
||
// isBackupStamp 判断 s 是否是 NowStamp() 的形状(20060102-150405)。
|
||
//
|
||
// 只认定长数字+一个分隔短横:定长保证字典序 == 时间序,数字保证
|
||
// 不会被任意名字混进来。
|
||
func isBackupStamp(s string) bool {
|
||
if len(s) != len("20060102-150405") {
|
||
return false
|
||
}
|
||
for i := 0; i < len(s); i++ {
|
||
if i == 8 {
|
||
if s[i] != '-' {
|
||
return false
|
||
}
|
||
continue
|
||
}
|
||
if s[i] < '0' || s[i] > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// pruneByPrefix 保留名字以 prefix 开头的最新 keep 个目录,其余删除。
|
||
func pruneByPrefix(dir, prefix string, keep int) {
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return // 目录还不存在(首次升级)就没什么可清的
|
||
}
|
||
var names []string
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
n := e.Name()
|
||
if !strings.HasPrefix(n, prefix) || len(n) == len(prefix) {
|
||
continue
|
||
}
|
||
// ★ 后缀必须是时间戳形态。
|
||
//
|
||
// 只查前缀不查后缀的话,一个名字碰巧以该前缀开头的目录(如手工建的
|
||
// homed-upgrade-backup-manual)会被当成备份,且因为 'm' > '9' 会排在
|
||
// 时间戳**前面**当作"最新" ⇒ 它挤掉真正的备份、然后真正的最新备份
|
||
// 反被删。GC 只应动它自己造的东西。
|
||
if !isBackupStamp(n[len(prefix):]) {
|
||
continue
|
||
}
|
||
names = append(names, n)
|
||
}
|
||
if len(names) <= keep {
|
||
return
|
||
}
|
||
// 名字里的时间戳定长且字典序 == 时间序(见文件头),倒序即最新在前。
|
||
sort.Sort(sort.Reverse(sort.StringSlice(names)))
|
||
for _, n := range names[keep:] {
|
||
p := filepath.Join(dir, n)
|
||
if err := os.RemoveAll(p); err != nil {
|
||
log.Printf("[update] prune %s failed: %v", p, err)
|
||
continue
|
||
}
|
||
log.Printf("[update] pruned old backup %s(保留最近 %d 个)", p, keep)
|
||
}
|
||
}
|