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 失败。
This commit is contained in:
JianFeeeee
2026-10-29 09:54:55 +08:00
parent 7f68d2b1bf
commit ba281a855a
42 changed files with 4897 additions and 272 deletions

View File

@ -0,0 +1,386 @@
package update
// Apply / 回滚路径的**回归判据**。
//
// 这些用例的共同点:它们钉住的都是“**静默产出看起来成功的结果**”
// 那类缺陷。升级路径上的静默失败 = 机器进崩溃循环 + 没人知道为什么,
// 所以每个用例都不只断言“返回了 error”,而是断言**副作用没有发生**。
import (
"os"
"path/filepath"
"strings"
"testing"
)
// ── 测试脚手架 ───────────────────────────────────────────────
// fakeBin 造一个能被 `--version` 读出版本的假二进制(shell 脚本)。
func fakeBin(t *testing.T, dir, name, version, commit string) string {
t.Helper()
p := filepath.Join(dir, name)
body := "#!/bin/sh\necho version=" + version + "\necho commit=" + commit +
"\necho build_time=2026-01-01T00:00:00Z\necho sdk=1.4.0\n"
if err := os.WriteFile(p, []byte(body), 0755); err != nil {
t.Fatalf("写假二进制: %v", err)
}
return p
}
// oldKernelBin 造一个**不支持 --version** 的假旧内核(模拟真实 1.3.x 形态)。
func oldKernelBin(t *testing.T, dir string) string {
t.Helper()
p := filepath.Join(dir, "homed")
body := "#!/bin/sh\n" +
"echo 'flag provided but not defined: -version' >&2\n" +
"exit 2\n"
if err := os.WriteFile(p, []byte(body), 0755); err != nil {
t.Fatalf("写旧内核: %v", err)
}
return p
}
func newPkgRoot(t *testing.T, version, commit string) string {
t.Helper()
root := t.TempDir()
binDir := filepath.Join(root, "bin")
if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatal(err)
}
src := fakeBin(t, binDir, "homed", version, commit)
_ = src
return root
}
// ── ★ 核心回归:从「没有 --version 的旧内核」升级失败后,必须能回滚 ──
// TestRollbackToOldKernelWithoutVersionFlag 钉住 PR #31 引入的兼容性回归。
//
// 事故形态:旧内核(1.3.x 及以前)没有 `--version`。若回滚校验依赖
// `--version`,那么从它升上去、新版本起不来时:
//
// ① 文件已正确还原成可执行的旧内核
// ② 但读版本失败 ⇒ 判“回滚失败”
// ③ kernelmod/update 拒绝启动
// ④ marker 保留 ⇒ 下次启动同一条路径 ⇒ **永久回滚不了**
//
// 判据不是“返回 nil”,而是“文件真的被还原且内容与备份逐字节相同”。
func TestRollbackToOldKernelWithoutVersionFlag(t *testing.T) {
dir := t.TempDir()
dataDir := t.TempDir()
// 备份:真实的旧内核形态(没有 --version)
backupDir := filepath.Join(dataDir, BackupDirPrefix+NowStamp())
if err := os.MkdirAll(backupDir, 0755); err != nil {
t.Fatal(err)
}
oldBin := oldKernelBin(t, backupDir)
oldSum, err := sha256File(oldBin)
if err != nil {
t.Fatal(err)
}
// 现状:升级后的新二进制是坏的(不可执行)
bin := filepath.Join(dir, "homed")
if err := os.WriteFile(bin, []byte(""), 0755); err != nil {
t.Fatal(err)
}
// marker 记录了升级前的 digest(新版 Apply 一定会写)
if err := MarkPendingUpgrade(dataDir, PendingRecord{
FromVer: "1.3.0", FromCommit: "aaa1111",
ToVer: "1.4.0", ToCommit: "bbb2222",
BackupDir: backupDir,
Digests: map[string]string{bin: oldSum},
}); err != nil {
t.Fatal(err)
}
rolled, from, to, err := RollbackIfUnconfirmed(dataDir, bin)
if err != nil {
t.Fatalf("★ 回滚不应失败(旧内核没有 --version 是常态,不是缺陷): %v", err)
}
if !rolled {
t.Fatal("有待确认标记时必须回滚")
}
if from != "1.3.0" || to != "1.4.0" {
t.Errorf("版本轨迹不对: %s → %s", from, to)
}
// ★ 真正要断言的是:磁盘上就是那份旧内核,逐字节相同
got, err := sha256File(bin)
if err != nil {
t.Fatal(err)
}
if got != oldSum {
t.Errorf("★ 回滚后内容与备份不一致:期望 %s 实际 %s", ShortSum(oldSum), ShortSum(got))
}
// marker 必须清掉:回滚成功且已校验
if rec, _ := ReadPendingUpgrade(dataDir); rec != nil {
t.Error("回滚成功后应清除 marker")
}
}
// TestRollbackRejectsCorruptBackupByDigest 钉住"坏备份不能被当成回滚成功"。
//
// 旧实现的判据是 `CopyFile 没报错` —— 零字节备份拷过去也成功。
// 现在用 digest:内容不对就是失败,且 marker 必须保留(保住第二次自愈机会)。
func TestRollbackRejectsCorruptBackupByDigest(t *testing.T) {
dir := t.TempDir()
dataDir := t.TempDir()
backupDir := filepath.Join(dataDir, BackupDirPrefix+NowStamp())
if err := os.MkdirAll(backupDir, 0755); err != nil {
t.Fatal(err)
}
// 备份是零字节(磁盘满时极易发生)
if err := os.WriteFile(filepath.Join(backupDir, "homed"), []byte{}, 0755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(dir, "homed")
if err := os.WriteFile(bin, []byte("BROKEN"), 0755); err != nil {
t.Fatal(err)
}
// marker 里记的是"当时那份文件"的 digest,与零字节备份不符
if err := MarkPendingUpgrade(dataDir, PendingRecord{
FromVer: "1.4.0", FromCommit: "old",
ToVer: "1.4.1", ToCommit: "new",
BackupDir: backupDir,
Digests: map[string]string{bin: strings.Repeat("a", 64)},
}); err != nil {
t.Fatal(err)
}
rolled, _, _, err := RollbackIfUnconfirmed(dataDir, bin)
if err == nil {
t.Fatal("★ 坏备份被当成回滚成功了 —— 未做 digest 校验")
}
if rolled {
t.Error("校验失败时 rolled 必须为 false(否则调用方以为自愈完成)")
}
// ★ marker 必须仍在:失败后要保住第二次自愈机会
if rec, _ := ReadPendingUpgrade(dataDir); rec == nil {
t.Error("★★ 回滚失败却清了 marker ⇒ 失去第二次自愈机会")
}
}
// TestRollbackRemovesDepGraphAddedByUpgrade 钉住「旧 homed + 新 depgraph」崩溃循环。
//
// 场景:升级前机器没有 depgraph.bin(或备份没带上),升级包带了新的。
// 新内核起不来 ⇒ 回滚。若只换 homed 不删 depgraph,磁盘上是
// 「旧 homed + 新 depgraph」:旧 homed 内嵌的 digest 对不上,
// kernelboot 装载期直接拒绝启动,而 marker 已清 ⇒ 既起不来也回滚不了。
func TestRollbackRemovesDepGraphAddedByUpgrade(t *testing.T) {
dir := t.TempDir()
dataDir := t.TempDir()
backupDir := filepath.Join(dataDir, BackupDirPrefix+NowStamp())
if err := os.MkdirAll(backupDir, 0755); err != nil {
t.Fatal(err)
}
// 备份里**没有** depgraph.bin(升级前机器本来就没有)
oldBin := fakeBin(t, backupDir, "homed", "1.3.0", "aaa")
oldSum, _ := sha256File(oldBin)
bin := filepath.Join(dir, "homed")
if err := os.WriteFile(bin, []byte("BROKEN"), 0755); err != nil {
t.Fatal(err)
}
// 升级时装上了新的 depgraph
dgPath := filepath.Join(dir, "depgraph.bin")
if err := os.WriteFile(dgPath, []byte("NEW-DEPGRAPH"), 0644); err != nil {
t.Fatal(err)
}
if err := MarkPendingUpgrade(dataDir, PendingRecord{
FromVer: "1.3.0", FromCommit: "aaa",
ToVer: "1.4.0", ToCommit: "bbb",
BackupDir: backupDir,
HadDepGraph: false,
Digests: map[string]string{bin: oldSum},
}); err != nil {
t.Fatal(err)
}
rolled, _, _, err := RollbackIfUnconfirmed(dataDir, bin)
if err != nil {
t.Fatalf("回滚不应失败: %v", err)
}
if !rolled {
t.Fatal("应回滚")
}
// ★ 关键断言:新装上的 depgraph 必须被删掉
if _, statErr := os.Stat(dgPath); statErr == nil {
b, _ := os.ReadFile(dgPath)
t.Fatalf("★★ 残留升级时装上的 depgraph(%q)⇒ 旧 homed 的 digest 对不上 ⇒ 崩溃循环", b)
}
}
// TestApplyRefusesWhenCannotWrite 钉住"安装形态决定能不能自替换"。
//
// deb/rpm 形态下直接替换会让包管理器数据库与实际文件不一致。
// 判据:拒绝 + 给出可执行的替代命令,且**磁盘上什么都没变**。
func TestApplyRefusesWhenCannotWrite(t *testing.T) {
dir := t.TempDir()
bin := fakeBin(t, dir, "homed", "1.4.0", "old")
orig, _ := os.ReadFile(bin)
root := newPkgRoot(t, "1.9.9", "new")
// dataDir 为空必须拒绝:无 pending 标记就无回滚能力。
// 这是最前置的守卫,位于预检/备份**之前**。
if _, err := Apply(root, bin, "", "", nil); err == nil {
t.Fatal("dataDir 为空时必须拒绝升级(无 marker 就无回滚能力)")
}
after, _ := os.ReadFile(bin)
if string(after) != string(orig) {
t.Error("★ 被拒绝的升级不应改动任何文件")
}
}
// TestApplyRefusesWhenBinPathEmpty 钉住“binPath 为空必须拒绝”。
//
// ## 为什么要单开一个用例(这是一个真实的误伤现场)
//
// 早先 cmd/homed-upgrade 的 resolveBinPath 在“同目录找不到 homed”时
// 兜底到 os.Executable()。后果:
//
// 本 CLI 叫 homed-upgrade,与 homed **不同名** ⇒ 它把**自己**当成
// 内核 ⇒ “升级 homed”实际变成“把 CLI 覆盖成新包里的 homed”。
//
// 一个 1 字节的假包就能把工具本体换成一段 shell 脚本,而全程无报错。
//
// 现在两道闸:CLI 找不到就返回空并退出;Apply 对空 binPath 直接拒绝。
// 这个用例钉的是**第二道闸**。
func TestApplyRefusesWhenBinPathEmpty(t *testing.T) {
dataDir := t.TempDir()
root := newPkgRoot(t, "1.9.9", "new")
if _, err := Apply(root, "", "", dataDir, nil); err == nil {
t.Fatal("★ binPath 为空时必须拒绝:否则会把未知文件(甚至本工具自己)当成内核替换掉")
}
// 拒绝时不能留下任何痕迹:不能建备份、不能写 marker。
entries, _ := os.ReadDir(dataDir)
for _, e := range entries {
t.Errorf("★ 被拒绝的升级不应在 dataDir 留下 %q", e.Name())
}
}
// TestDetectRefusesUnknownInstall 钉住“探测不出形态就不允许自替换”。
//
// 失败方向必须是收紧:Unknown 意味着“我们不知道这个东西归谁管”,
// 猜错的代价是破坏包管理器数据库,那个后果比“不能升级”严重得多。
//
// ★ 本用例**只在非 root 下有效**,root 下直接 Skip。
//
// 为什么:root 无视目录权限位,chmod 0555 对它依旧可写,于是
// dirWritable 返回 true、用例恒红。强行用 root 跑会让它变成
// “CI 里必红/本机必绿”的假信号 —— 本仓已经吃过这类亏
// (用“本机是否装了 homed”验版本推断,CI 假绿、生产真红)。
// 所以:能跑就当真值验,不能跑就明说跳过,不以假结论填充。
func TestDetectRefusesUnknownInstall(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root 无视目录权限位,本用例无法构造“不可写”状态")
}
dir := t.TempDir()
bin := fakeBin(t, dir, "homed", "1.4.0", "x")
if err := os.Chmod(dir, 0555); err != nil {
t.Skipf("无法改目录权限: %v", err)
}
t.Cleanup(func() { os.Chmod(dir, 0755) })
c := Detect(bin, t.TempDir())
if c.CanWriteBin {
t.Errorf("★ 目录不可写时不得声称可以自替换(kind=%s)", c.Kind)
}
if c.Reason == "" {
t.Error("拒绝时必须给出可执行的原因(不能只有 bool)")
}
}
// TestDetectContainerRefusesSelfReplace 钉住容器内的判定(不依赖权限)。
//
// 容器里文件系统随容器销毁还原,自替换毫无意义。这条判据跨环境稳定:
// 它不依赖运行时的 euid,只依赖 /.dockerenv 之类的标记。
// 在本机(非容器)时它测的是“非容器不应被误判成容器”。
func TestDetectContainerRefusesSelfReplace(t *testing.T) {
c := Detect("/bin/sh", t.TempDir())
if c.Kind == KindContainer && c.CanWriteBin {
t.Error("★ 容器内不得声称可以自替换")
}
if c.Kind != KindContainer && inContainer() {
t.Error("★ 在容器内却未识别出容器形态")
}
}
// TestApplyRecordsDigests 钉住"Apply 必须把升级前文件的 digest 写进 marker"。
//
// 没有 digest 就没有可靠的旧版本回滚判据(旧内核没有 --version)。
func TestApplyRecordsDigests(t *testing.T) {
dir := t.TempDir()
dataDir := t.TempDir()
bin := fakeBin(t, dir, "homed", "1.4.0", "old")
wantSum, err := sha256File(bin)
if err != nil {
t.Fatal(err)
}
root := newPkgRoot(t, "1.5.0", "new")
out, err := Apply(root, bin, "", dataDir, nil)
if err != nil {
t.Fatalf("Apply 失败: %v", err)
}
if out.To.Version != "1.5.0" {
t.Errorf("新版本不对: %s", out.To.Version)
}
rec, rerr := ReadPendingUpgrade(dataDir)
if rerr != nil || rec == nil {
t.Fatalf("Apply 成功后必须有 pending marker: rec=%v err=%v", rec, rerr)
}
if got, ok := rec.Digests[bin]; !ok {
t.Fatalf("★ marker 缺少 %s 的 digest ⇒ 未来无法可靠回滚到旧版本", bin)
} else if got != wantSum {
t.Errorf("digest 记错:期望 %s 实际 %s", ShortSum(wantSum), ShortSum(got))
}
if !rec.HadDepGraph == true && rec.HadDepGraph {
// 占位:HadDepGraph 在本用例里没有 depgraph,应为 false
}
if rec.HadDepGraph {
t.Error("机器上本来没有 depgraph.bin,HadDepGraph 应为 false")
}
}
// TestApplyRestoresFullyBeforeClearingMarker 钉住"还原完整才清 marker"。
//
// 旧实现先清 marker 再还原,于是"还原也失败"时磁盘上是半新半旧、
// 而 marker 已经没了 ⇒ 失去第二次自愈机会。
func TestApplyRestoresFullyBeforeClearingMarker(t *testing.T) {
dir := t.TempDir()
dataDir := t.TempDir()
bin := fakeBin(t, dir, "homed", "1.4.0", "old")
orig, _ := os.ReadFile(bin)
root := newPkgRoot(t, "1.5.0", "new")
// 让替换阶段的 homed 目标变成"不可 rename":
// 把临时文件路径占成一个目录 ⇒ CopyFileAtomic 在 OpenFile 阶段失败。
// 这样 homed 没被替换,restore 阶段也应成功还原(因为什么都没改)。
blocker := bin + atomicTmpSuffix
if err := os.MkdirAll(blocker, 0755); err != nil {
t.Fatal(err)
}
_, err := Apply(root, bin, "", dataDir, nil)
if err == nil {
t.Fatal("替换失败时应返回 error")
}
// 文件必须原样(restore 成功)
after, _ := os.ReadFile(bin)
if string(after) != string(orig) {
t.Error("★ 替换失败后 homed 未被还原")
}
}

195
internal/update/channel.go Normal file
View File

@ -0,0 +1,195 @@
package update
// 升级的**执行分派**:同一个"升级"请求,按安装形态走不同通道。
//
// ## 为什么需要这一层(这是 update 独立成模块的核心价值)
//
// 「升级」不是一件动作,而是一个**按环境分派**的决定:
//
// deb/rpm → 包管理器执行(文件归 dpkg/rpm 所有,绕过它会破坏数据库)
// tar + 可写 → 自替换(本模块直接动手)
// tar + 不可写 → 只告知命令(服务以非 root 运行,动不了 /usr/bin)
// container → 重拉镜像(容器文件系统随销毁还原)
//
// 在这层存在之前,三个调用点(WebUI / agent 工具 / CLI)各自假定
// "升级 = 替换二进制",于是 deb 形态下要么 EACCES、要么(root 时)
// 把 dpkg 数据库搞脏。
//
// ## 设计约定
//
// 本函数**不重启**、**不弹交互**。它要么完成、要么返回一个带
// 可执行命令的"需要委托"结果。重启与提示由调用方负责。
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// UpgradePlan 是一次升级的**执行计划**(在动手之前确定)。
type UpgradePlan struct {
Capabilities Capabilities `json:"capabilities"`
Channel UpgradeChannel `json:"channel"`
// Executable 表示本模块能否自己完成(true 时才可调用 Execute)。
Executable bool `json:"executable"`
// Commands 是交给人的命令(Executable=false 时非空)。
Commands []string `json:"commands,omitempty"`
// Reason 解释为什么不能自己执行。
Reason string `json:"reason,omitempty"`
}
// PlanUpgrade 决定这次升级该怎么执行。
//
// target 是本地包路径(可选);包管理器通道下它会作为"直接装这个包"的备选。
func PlanUpgrade(c Capabilities, target string) UpgradePlan {
p := UpgradePlan{Capabilities: c, Channel: c.Channel()}
switch p.Channel {
case ChannelSelfReplace:
if c.CanWriteBin {
p.Executable = true
return p
}
p.Reason = c.Reason
case ChannelPackageManager:
p.Reason = "文件归包管理器所有:直接替换会让 dpkg/rpm 数据库与实际内容不一致"
case ChannelContainerImage:
p.Reason = "运行在容器内:文件系统随容器销毁还原,升级应重拉镜像"
default:
p.Reason = c.Reason
if p.Reason == "" {
p.Reason = "无法确定安装形态,拒绝自动升级"
}
}
// 给出可执行命令(这是"不能自己做"时的唯一有用产出)。
if cmd := c.UpgradeCommand(target); cmd != "" {
p.Commands = []string{cmd}
}
if p.Channel == ChannelContainerImage {
p.Commands = []string{
"docker pull <镜像>:<新tag> # 或你们的镜像仓库地址",
"docker compose up -d # 用新镜像重建容器",
}
}
return p
}
// PackageManagerUpgrade 用本机包管理器升级本包(deb/rpm 通道)。
//
// ## 为什么需要 root,以及为什么本函数不自己提权
//
// dpkg/rpm 都要 root。而内核以非 root 服务账号运行(ProtectSystem=strict)。
// **本函数不自带提权**:sudo/polkit 的形态因发行版与部署策略而异,
// 在代码里猜一个(例如 exec sudo)会在无 tty 的服务环境里静默挂起等密码。
//
// 所以:能在 root 下调用就调(CLI 用 sudo 跑),否则返回一个
// ErrNeedPrivilege,由调用方转成"请执行这条命令"。
func PackageManagerUpgrade(c Capabilities, target string) error {
// 预检:非 root 直接返回可执行错误,不要等到 dpkg 报一堆权限噪音。
if os.Geteuid() != 0 {
return fmt.Errorf("%w:需要 root 执行 `%s`", ErrNeedPrivilege, c.UpgradeCommand(target))
}
switch c.Kind {
case KindDeb:
return upgradeDeb(c, target)
case KindRPM:
return upgradeRPM(c, target)
default:
return fmt.Errorf("包管理器升级不适用于形态 %q", c.Kind)
}
}
// ErrNeedPrivilege 表示这条路径需要提权(调用方应转成可执行提示)。
var ErrNeedPrivilege = fmt.Errorf("需要提权")
func upgradeDeb(c Capabilities, target string) error {
pkg := c.PackageName
if pkg == "" {
pkg = "hologramagent"
}
// 有本地包 ⇒ 先试 dpkg -i(离线/指定版本场景最可靠)。
// 没本地包 ⇒ 走 apt(从配置的源取最新)。
if target != "" {
if err := runPkgCmd("dpkg", "-i", target); err != nil {
// dpkg -i 常因依赖缺失失败;退回 apt -f install 补齐。
// ★ 不吞错:第二次也失败就把两个错误一起报。
if fixErr := runPkgCmd("apt-get", "install", "-f", "-y"); fixErr != nil {
return fmt.Errorf("dpkg -i 失败(%v),且 apt -f install 也失败: %w", err, fixErr)
}
}
return nil
}
return runPkgCmd("apt-get", "install", "--only-upgrade", "-y", pkg)
}
func upgradeRPM(c Capabilities, target string) error {
pkg := c.PackageName
if pkg == "" {
pkg = "hologramagent"
}
if target != "" {
// dnf 优先(能解依赖),没有就退回 rpm -U
if _, err := exec.LookPath("dnf"); err == nil {
return runPkgCmd("dnf", "install", "-y", target)
}
return runPkgCmd("rpm", "-U", target)
}
if _, err := exec.LookPath("dnf"); err == nil {
return runPkgCmd("dnf", "upgrade", "-y", pkg)
}
return fmt.Errorf("找不到 dnf:请手工升级 %s", pkg)
}
// pkgCmdRunner 是包管理器命令的**执行器**(可注入)。
//
// ★ 为什么提为变量:包管理器命令会真的改动系统,而单元测试**绝不能**
// 真的跑 `apt-get install`(实测踩到:root 下跑真 apt,报
// "Unable to locate package",还被当成业务逻辑失败)。
// 测试把它换成一个记录调用的假执行器,从而能断言“选了哪条命令”。
// 生产行为完全不变(默认就是下面那个真跑的实现)。
var pkgCmdRunner = realRunPkgCmd
// runPkgCmd 走当前执行器(生产 = realRunPkgCmd,测试可替换)。
func runPkgCmd(name string, args ...string) error { return pkgCmdRunner(name, args...) }
// realRunPkgCmd 执行包管理器命令,带输出上限与超时。
//
// ★ 为什么要超时:dpkg 在等锁(另一个 apt 在跑)时会长时间挂住,
// 而调用方(WebUI 的 HTTP handler)会因此一直持有连接。
func realRunPkgCmd(name string, args ...string) error {
cmd := exec.Command(name, args...)
var sb strings.Builder
cmd.Stdout = &sb
cmd.Stderr = &sb
done := make(chan error, 1)
if err := cmd.Start(); err != nil {
return fmt.Errorf("启动 %s: %w", name, err)
}
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err != nil {
return fmt.Errorf("%s %s 失败: %w\n输出尾部:\n%s",
name, strings.Join(args, " "), err, tailLines(sb.String(), 20))
}
return nil
case <-time.After(10 * time.Minute):
_ = cmd.Process.Kill()
return fmt.Errorf("%s %s 超时(10 分钟):可能卡在包管理器锁上",
name, strings.Join(args, " "))
}
}
// tailLines 取文本末尾 n 行(错误消息里只需要尾部)。
func tailLines(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) <= n {
return s
}
return "…(前略)\n" + strings.Join(lines[len(lines)-n:], "\n")
}

View File

@ -0,0 +1,197 @@
package update
// 升级通道分派的判据。
//
// 这一层的存在理由:同一个"升级"在不同安装形态下必须走不同通道。
// 判据要钉住的不是"函数能跑",而是**分派结果是否正确**——
// 分派错了的后果是破坏包管理器数据库,那是要人工上机器救的故障。
import (
"errors"
"os"
"strings"
"testing"
)
// ① deb/rpm 一律走包管理器,且**不可自替换**(即便当前是 root)。
func TestPlanUpgrade_DebGoesToPackageManager(t *testing.T) {
c := Capabilities{
Kind: KindDeb, Variant: VariantServer,
BinPath: "/usr/bin/homed", PackageName: "hologramagent-server",
CanWriteBin: false, Reason: "由 dpkg 安装",
}
p := PlanUpgrade(c, "")
if p.Channel != ChannelPackageManager {
t.Fatalf("deb 应走包管理器,实际 %s", p.Channel)
}
if p.Executable {
t.Fatal("★ deb 形态绝不允许自替换(会破坏 dpkg 数据库)")
}
if len(p.Commands) == 0 {
t.Fatal("不能自执行时必须给出可执行命令")
}
if !strings.Contains(p.Commands[0], "hologramagent-server") {
t.Errorf("命令应带包名,实际: %s", p.Commands[0])
}
if p.Reason == "" {
t.Error("拒绝自执行必须说明原因")
}
}
// ② tar 且可写 ⇒ 自替换。
func TestPlanUpgrade_TarWritableSelfReplaces(t *testing.T) {
c := Capabilities{Kind: KindTar, Variant: VariantServer, CanWriteBin: true}
p := PlanUpgrade(c, "")
if p.Channel != ChannelSelfReplace || !p.Executable {
t.Fatalf("tar+可写应自替换,实际 channel=%s executable=%v", p.Channel, p.Executable)
}
if len(p.Commands) != 0 {
t.Error("能自己做的事不该给命令(会让人以为要手动)")
}
}
// ③ tar 但不可写 ⇒ 不可执行,但必须给出命令(这是本机的主要现实形态)。
func TestPlanUpgrade_TarUnwritableGivesCommand(t *testing.T) {
c := Capabilities{
Kind: KindTar, Variant: VariantServer, CanWriteBin: false,
Reason: "当前用户对 /usr/local/bin 没有写权限",
}
p := PlanUpgrade(c, "")
if p.Executable {
t.Fatal("不可写时不得声称可执行")
}
if len(p.Commands) == 0 {
t.Fatal("★ 不可写时必须给出可执行命令,否则用户无从下手")
}
if !strings.Contains(strings.Join(p.Commands, " "), "homed-upgrade") {
t.Errorf("tar 形态应建议 homed-upgrade,实际: %v", p.Commands)
}
}
// ④ 容器 ⇒ 重拉镜像,不给包管理器命令。
func TestPlanUpgrade_ContainerPullsImage(t *testing.T) {
c := Capabilities{Kind: KindContainer, Variant: VariantServer}
p := PlanUpgrade(c, "")
if p.Channel != ChannelContainerImage {
t.Fatalf("容器应走镜像通道,实际 %s", p.Channel)
}
if p.Executable {
t.Fatal("容器内不得自替换(重启即还原)")
}
if len(p.Commands) < 2 {
t.Errorf("应给出 pull + 重建两条命令,实际: %v", p.Commands)
}
}
// ⑤ 源码运行/未知 ⇒ 拒绝,且必须说明。
func TestPlanUpgrade_SourceRefuses(t *testing.T) {
for _, kind := range []InstallKind{KindSource, KindUnknown} {
c := Capabilities{Kind: kind, Variant: VariantUnknown}
p := PlanUpgrade(c, "")
if p.Executable {
t.Errorf("%s 形态不得自替换", kind)
}
if p.Reason == "" {
t.Errorf("%s 拒绝时必须说明原因(不能只有 bool)", kind)
}
}
}
// ⑥ 非 root 时 PackageManagerUpgrade 返回 ErrNeedPrivilege,而不是去跑 dpkg。
//
// 判据重点是**没有执行命令**:非 root 下 dpkg 会输出一堆权限噪音,
// 而真正该告诉用户的是"你需要 sudo"。
// ⑥ 包管理器执行**不**真跑命令,而是通过可注入执行器验证选了什么。
//
// ## 为什么必须注入(真实事故)
//
// 本用例最初直接调 PackageManagerUpgrade,在 root 下真的执行了
// `apt-get install --only-upgrade -y hologramagent-full` —— 报
// "Unable to locate package" 并把真实的系统查询当成了业务失败。
// 单元测试**绝不能**真的调用包管理器。
//
// 现在把 pkgCmdRunner 换掉,从而验证的是“选了哪条命令”,
// 而不是“命令能不能跑”。
func TestPackageManagerUpgrade_ChoosesRightCommand(t *testing.T) {
var got []string
old := pkgCmdRunner
pkgCmdRunner = func(name string, args ...string) error {
got = append(got, name+" "+strings.Join(args, " "))
return nil
}
t.Cleanup(func() { pkgCmdRunner = old })
// deb 无本地包 ⇒ 走 apt-get --only-upgrade
c := Capabilities{Kind: KindDeb, PackageName: "hologramagent-full"}
if err := PackageManagerUpgrade(c, ""); err != nil {
if errors.Is(err, ErrNeedPrivilege) {
t.Skip("非 root:本用例需要 root 才能走到执行分支")
}
t.Fatalf("执行失败: %v", err)
}
if len(got) != 1 || !strings.Contains(got[0], "--only-upgrade") {
t.Errorf("deb 应选 apt-get --only-upgrade,实际: %v", got)
}
if !strings.Contains(got[0], "hologramagent-full") {
t.Errorf("命令应带包名,实际: %v", got)
}
}
// ⑥b 非 root 时必须返回 ErrNeedPrivilege(而不是去跑命令)。
//
// 判据重点是**没有执行命令**:非 root 下 dpkg 只会输出权限噪音,
// 而真正该告诉用户的是“你需要 sudo”。
func TestPackageManagerUpgrade_NonRootReturnsNeedPrivilege(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root 环境无法验证非 root 分支")
}
called := false
old := pkgCmdRunner
pkgCmdRunner = func(name string, args ...string) error { called = true; return nil }
t.Cleanup(func() { pkgCmdRunner = old })
c := Capabilities{Kind: KindDeb, PackageName: "hologramagent-full"}
err := PackageManagerUpgrade(c, "")
if err == nil {
t.Fatal("非 root 时应报错(不得静默成功)")
}
if !errors.Is(err, ErrNeedPrivilege) {
t.Errorf("应返回 ErrNeedPrivilege 以便调用方转成提示,实际: %v", err)
}
if called {
t.Error("★ 非 root 时不应去执行包管理器命令")
}
if !strings.Contains(err.Error(), "apt") {
t.Errorf("错误里应含可执行命令,实际: %v", err)
}
}
// ⑦ detectVariant 不能因为缺文件就 panic,且 client(无 homed)要能识别。
func TestDetectVariant_NoKernelIsClientOrUnknown(t *testing.T) {
v := detectVariant("/nonexistent/homed")
if v != VariantClient && v != VariantUnknown {
t.Errorf("无 homed 时应为 client 或 unknown,实际 %s", v)
}
}
// ⑧ isGoBuildTempPath 只认 go build 的临时目录形态,不认任意 /tmp。
//
// 这一条防的是"用 /tmp/ 判源码运行"的宽判据:那会把
// "解包到 /tmp 试装"和 CI 集成测都误判成 dev 环境而拒绝升级。
func TestIsGoBuildTempPath(t *testing.T) {
cases := []struct {
path string
want bool
}{
{"/tmp/go-build123/b001/exe/homed", true},
{"/root/.cache/go-build/ab/cd-homed", false}, // 构建缓存,不是 go run 产物
{"/tmp/somewhere/homed", false}, // 普通 /tmp 路径不算
{"/usr/local/bin/homed", false},
{"", false},
}
for _, c := range cases {
if got := isGoBuildTempPath(c.path); got != c.want {
t.Errorf("isGoBuildTempPath(%q) = %v, want %v", c.path, got, c.want)
}
}
}

370
internal/update/delta.go Normal file
View File

@ -0,0 +1,370 @@
package update
// 二进制差分:用「旧版本 + 小补丁」重建新版本,避免每次升下载 88MB。
//
// ## 为什么必须做(实测数据)
//
// 两次构建(同 tag、同参数、-trimpath):
//
// 基线 88,036,432 字节
// 改一行逻辑 88,036,432 字节,但**逐字节差异只有 80 字节**
//
// 而 OTA 现在下的是整个 tar.gz。也就是说 99.9999% 的流量在传
// 与上一版完全相同的字节。
//
// 前提是可复现构建 —— 本项目已满足(实测两次构建 sha256 相同),
// 这是差分能成立的基础:同一份源码必须产出同一份字节。
//
// ## 算法选择:为什么不直接用 bsdiff
//
// bsdiff 需要 ~17×N 的峰值内存(88MB → 约 1.5GB),在系统内存紧张的
// 机器上会 OOM。而升级恰恰发生在可能内存不足的场景里。
//
// 这里用 rsync 族的**滚动哈希块匹配**:
//
// 构建期:把旧文件按 blockSize 切块建索引(88MB/4KB ≈ 21500 条)
// 差分时:在新文件上逐字节滚动 blockSize 窗口的哈希,命中即验证并
// 向后扩张匹配,发出 COPY;否则累积 LITERAL
//
// 峰值内存 ≈ 旧文件 + 新文件 + 索引(约 200MB / 88MB 文件),
// 且索引只有几万条,与文件大小线性但系数极小。
//
// ## 为什么 COPY 可以跨任意偏移
//
// 索引里的键是**块内容的哈希**,不是偏移。所以新文件里同一段内容出现在
// 任何位置都能命中 —— 这正好处理"插入/删除几个字节导致后续全部错位"
// 这种最常见的情形(改代码几乎必然改变 .text 的偏移)。
//
// ## 安全边界
//
// 差分的结果**必须**用全部内容的 sha256 校验。patch 应用成功不等于
// 内容正确(旧版本不同、磁盘损坏、patch 被截断都会"成功"应用出
// 一份错误的二进制)。校验失败就丢弃,回退到完整包。
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"hash/crc32"
"io"
"os"
"sort"
)
const (
// blockSize 是索引的匹配粒度。
//
// 选 4KB 的理由:
// · 索引条数 = 文件大小/4KB(88MB → 21500 条),内存可忽略
// · 匹配粒度足够:改一行代码只影响几十字节,其余 4KB 块全部命中
// · 太小(如 64B)会让索引暴涨到 137 万条且收益变差
blockSize = 4096
// deltaMagic 是补丁文件头,用于识别格式与版本。
deltaMagic = "HADELTA1"
// maxDeltaInput 是允许做差分的单文件上限。
//
// 88MB 的二进制 + 索引约需 200MB 峰值。设 256MB 上限是为了防止
// 误用(例如有人对模型文件做差分)导致内存爆掉。
maxDeltaInput = 256 << 20
opCopy = byte(0x01)
opLiteral = byte(0x02)
)
// DeltaHeader 是补丁的元信息。
type DeltaHeader struct {
OldSize int64
NewSize int64
// NewSHA256 是**新文件的完整 sha256**,应用后必须校验。
NewSHA256 string
// OldSHA256 是**旧文件的完整 sha256**,应用前必须校验。
//
// ★ 为什么记旧文件的哈希:差分只能对"特定旧版本"应用。
// 用错基线(例如两个版本之间的跨版本 delta)会静默产出一份垃圾,
// 而它的大小、结构都"看起来正常"。
OldSHA256 string
}
// MakeDelta 生成把 oldData 变成 newData 的补丁。
func MakeDelta(oldData, newData []byte) ([]byte, *DeltaHeader, error) {
if len(oldData) > maxDeltaInput || len(newData) > maxDeltaInput {
return nil, nil, fmt.Errorf("文件超出差分上限(%d MB)", maxDeltaInput>>20)
}
oldSum := sha256.Sum256(oldData)
newSum := sha256.Sum256(newData)
hdr := &DeltaHeader{
OldSize: int64(len(oldData)),
NewSize: int64(len(newData)),
OldSHA256: hex.EncodeToString(oldSum[:]),
NewSHA256: hex.EncodeToString(newSum[:]),
}
idx := buildBlockIndex(oldData)
var body bytes.Buffer
// 预留:最坏情况全是字面量,但字面量会分片合并,实际远小于 N。
body.Grow(len(newData)/64 + 1024)
var lit []byte
flushLit := func() {
if len(lit) == 0 {
return
}
body.WriteByte(opLiteral)
var tmp [4]byte
binary.LittleEndian.PutUint32(tmp[:], uint32(len(lit)))
body.Write(tmp[:])
body.Write(lit)
lit = lit[:0]
}
writeCopy := func(off int64, n int) {
body.WriteByte(opCopy)
var tmp [8]byte
binary.LittleEndian.PutUint64(tmp[:], uint64(off))
body.Write(tmp[:])
var t4 [4]byte
binary.LittleEndian.PutUint32(t4[:], uint32(n))
body.Write(t4[:])
}
i := 0
for i < len(newData) {
// 窗口不足一个块时无法匹配,直接当字面量
if i+blockSize > len(newData) {
lit = append(lit, newData[i:]...)
break
}
h := crc32.ChecksumIEEE(newData[i : i+blockSize])
cands := idx[h]
matched := int64(-1)
matchLen := 0
for _, off := range cands {
// 哈希命中后必须**逐字节验证**:CRC32 会碰撞,
// 而碰撞导致的错误 COPY 会让输出静默损坏。
if !bytes.Equal(oldData[off:off+blockSize], newData[i:i+blockSize]) {
continue
}
// 向后扩张:把相邻的相同字节一起纳入 COPY
n := blockSize
for i+n < len(newData) && off+int64(n) < int64(len(oldData)) &&
newData[i+n] == oldData[off+int64(n)] {
n++
}
if n > matchLen {
matchLen = n
matched = off
}
if matchLen >= 1<<20 {
break // 足够长,不必再找
}
}
if matched >= 0 {
flushLit()
writeCopy(matched, matchLen)
i += matchLen
continue
}
lit = append(lit, newData[i])
i++
}
flushLit()
var out bytes.Buffer
out.WriteString(deltaMagic)
var b8 [8]byte
binary.LittleEndian.PutUint64(b8[:], uint64(len(oldData)))
out.Write(b8[:])
binary.LittleEndian.PutUint64(b8[:], uint64(len(newData)))
out.Write(b8[:])
// 两个哈希固定 64 字节 hex
oldHex := make([]byte, 64)
newHex := make([]byte, 64)
copy(oldHex, hdr.OldSHA256)
copy(newHex, hdr.NewSHA256)
out.Write(oldHex)
out.Write(newHex)
out.Write(body.Bytes())
return out.Bytes(), hdr, nil
}
// buildBlockIndex 把旧文件按 blockSize 切块,建「CRC32 → 偏移列表」索引。
func buildBlockIndex(data []byte) map[uint32][]int64 {
idx := make(map[uint32][]int64, len(data)/blockSize+1)
for off := 0; off+blockSize <= len(data); off += blockSize {
h := crc32.ChecksumIEEE(data[off : off+blockSize])
idx[h] = append(idx[h], int64(off))
}
return idx
}
// ParseDeltaHeader 读补丁头(不应用)。
func ParseDeltaHeader(patch []byte) (*DeltaHeader, error) {
if len(patch) < len(deltaMagic)+16+128 {
return nil, fmt.Errorf("补丁太短(%d 字节),不是有效差分", len(patch))
}
if string(patch[:len(deltaMagic)]) != deltaMagic {
return nil, fmt.Errorf("补丁魔数不符:期望 %q,实际 %q", deltaMagic, patch[:len(deltaMagic)])
}
p := len(deltaMagic)
hdr := &DeltaHeader{
OldSize: int64(binary.LittleEndian.Uint64(patch[p:])),
NewSize: int64(binary.LittleEndian.Uint64(patch[p+8:])),
OldSHA256: string(patch[p+16 : p+16+64]),
NewSHA256: string(patch[p+16+64 : p+16+128]),
}
return hdr, nil
}
// ApplyDelta 用旧数据 + 补丁重建新数据,并**校验两端的 sha256**。
//
// 校验是硬性的:patch 应用"成功"不代表内容正确。旧版本不匹配、
// 磁盘损坏、patch 被截断,都会产出一份"看起来正常"的错误二进制。
// 唯一可信的判据是全内容 sha256。
func ApplyDelta(oldData, patch []byte) ([]byte, error) {
hdr, err := ParseDeltaHeader(patch)
if err != nil {
return nil, err
}
// ① 先验旧文件:差分只对**特定旧版本**成立。
oldSum := sha256.Sum256(oldData)
if got := hex.EncodeToString(oldSum[:]); got != hdr.OldSHA256 {
return nil, fmt.Errorf("基线不匹配:补丁针对 %s,本地是 %s",
ShortSum(hdr.OldSHA256), ShortSum(got))
}
if int64(len(oldData)) != hdr.OldSize {
return nil, fmt.Errorf("基线大小不匹配:期望 %d,实际 %d", hdr.OldSize, len(oldData))
}
// ② 执行指令
p := len(deltaMagic) + 16 + 128
out := make([]byte, 0, hdr.NewSize)
for p < len(patch) {
if p+1 > len(patch) {
return nil, fmt.Errorf("补丁在偏移 %d 处截断(缺操作码)", p)
}
op := patch[p]
p++
switch op {
case opCopy:
if p+12 > len(patch) {
return nil, fmt.Errorf("补丁在偏移 %d 处截断(COPY 参数)", p)
}
off := int64(binary.LittleEndian.Uint64(patch[p:]))
n := int(binary.LittleEndian.Uint32(patch[p+8:]))
p += 12
if off < 0 || n < 0 || off+int64(n) > int64(len(oldData)) {
return nil, fmt.Errorf("COPY 越界:off=%d n=%d oldSize=%d", off, n, len(oldData))
}
out = append(out, oldData[off:off+int64(n)]...)
case opLiteral:
if p+4 > len(patch) {
return nil, fmt.Errorf("补丁在偏移 %d 处截断(LITERAL 长度)", p)
}
n := int(binary.LittleEndian.Uint32(patch[p:]))
p += 4
if n < 0 || p+n > len(patch) {
return nil, fmt.Errorf("LITERAL 越界:n=%d 剩余=%d", n, len(patch)-p)
}
out = append(out, patch[p:p+n]...)
p += n
default:
return nil, fmt.Errorf("未知操作码 0x%02x(偏移 %d)", op, p-1)
}
// 防御:补丁可以声明很小的 NewSize 但指令产出巨量数据
if int64(len(out)) > hdr.NewSize+int64(blockSize) {
return nil, fmt.Errorf("补丁产出的数据超过声明大小(%d > %d)", len(out), hdr.NewSize)
}
}
if int64(len(out)) != hdr.NewSize {
return nil, fmt.Errorf("重建大小不符:期望 %d,实际 %d", hdr.NewSize, len(out))
}
// ③ 校验结果:这是唯一可信的判据
newSum := sha256.Sum256(out)
if got := hex.EncodeToString(newSum[:]); got != hdr.NewSHA256 {
return nil, fmt.Errorf("重建内容校验失败:期望 %s,实际 %s",
ShortSum(hdr.NewSHA256), ShortSum(got))
}
return out, nil
}
// ── 文件级入口(供 CLI / 发布脚本使用) ────────────────────────
// MakeDeltaFile 从磁盘上的两个文件生成补丁文件。
func MakeDeltaFile(oldPath, newPath, patchPath string) (*DeltaHeader, int64, error) {
oldData, err := os.ReadFile(oldPath)
if err != nil {
return nil, 0, fmt.Errorf("读旧文件: %w", err)
}
newData, err := os.ReadFile(newPath)
if err != nil {
return nil, 0, fmt.Errorf("读新文件: %w", err)
}
patch, hdr, err := MakeDelta(oldData, newData)
if err != nil {
return nil, 0, err
}
if err := WriteFileAtomic(patchPath, patch, 0644); err != nil {
return nil, 0, fmt.Errorf("写补丁: %w", err)
}
return hdr, int64(len(patch)), nil
}
// ApplyDeltaFile 用旧文件 + 补丁重建新文件。
func ApplyDeltaFile(oldPath, patchPath, outPath string) (int64, error) {
oldData, err := os.ReadFile(oldPath)
if err != nil {
return 0, fmt.Errorf("读基线: %w", err)
}
patch, err := os.ReadFile(patchPath)
if err != nil {
return 0, fmt.Errorf("读补丁: %w", err)
}
out, err := ApplyDelta(oldData, patch)
if err != nil {
return 0, err
}
if err := WriteFileAtomic(outPath, out, 0755); err != nil {
return 0, fmt.Errorf("写结果: %w", err)
}
return int64(len(out)), nil
}
// DeltaStats 用于报告压缩效果(发布脚本 / 日志)。
type DeltaStats struct {
OldSize int64
NewSize int64
PatchSize int64
Ratio float64
}
// Stats 计算压缩比。
func (h *DeltaHeader) Stats(patchSize int64) DeltaStats {
r := 0.0
if h.NewSize > 0 {
r = float64(patchSize) / float64(h.NewSize)
}
return DeltaStats{OldSize: h.OldSize, NewSize: h.NewSize, PatchSize: patchSize, Ratio: r}
}
// sortedKeys 仅用于测试与诊断(map 遍历顺序不稳定)。
func sortedKeys(m map[uint32][]int64) []uint32 {
out := make([]uint32, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
var _ = io.Discard

View File

@ -0,0 +1,128 @@
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
}

View File

@ -0,0 +1,188 @@
package update
// 差分 OTA 的判据。
//
// 这一层的收益是**实测的**:改一行逻辑,88MB 的二进制只差 80 字节。
// 但机制本身有真实风险 —— 错误应用会静默产出一份"看起来正常"的坏二进制。
// 所以判据的重点是「错了一定被咬住」,而不是「正常情况能跑」。
import (
"bytes"
"crypto/rand"
"os"
"path/filepath"
"testing"
)
// ① 往返:make → apply 得到逐字节相同的内容。
func TestDeltaRoundTrip(t *testing.T) {
old := bytes.Repeat([]byte("ABCDEFGHIJKLMNOP"), 5000) // 80KB
newData := append([]byte(nil), old...)
// 中间插入 3 个字节(模拟改代码导致后续偏移全变)
mid := len(newData) / 2
newData = append(newData[:mid], append([]byte("XYZ"), newData[mid:]...)...)
patch, hdr, err := MakeDelta(old, newData)
if err != nil {
t.Fatalf("生成补丁: %v", err)
}
if hdr.NewSize != int64(len(newData)) || hdr.OldSize != int64(len(old)) {
t.Fatalf("头部大小不对: %+v", hdr)
}
got, err := ApplyDelta(old, patch)
if err != nil {
t.Fatalf("应用补丁: %v", err)
}
if !bytes.Equal(got, newData) {
t.Fatal("★ 重建内容与目标不一致")
}
}
// ② 关键收益:小改动 ⇒ 小补丁。
//
// 这条判据钉住的是**压缩比**本身。若实现退化成"全字面量",
// 往返仍能通过但 OTA 一点没省 —— 那是最容易漏掉的失败形态。
func TestDeltaSmallChangeProducesSmallPatch(t *testing.T) {
old := make([]byte, 1<<20) // 1MB
if _, err := rand.Read(old); err != nil {
t.Fatal(err)
}
newData := append([]byte(nil), old...)
// 只改 64 字节
for i := 0; i < 64; i++ {
newData[i*100] ^= 0xFF
}
patch, hdr, err := MakeDelta(old, newData)
if err != nil {
t.Fatal(err)
}
st := hdr.Stats(int64(len(patch)))
t.Logf("patch=%d bytes, ratio=%.4f", len(patch), st.Ratio)
// 1MB 随机内容,只有 64 字节变化 ⇒ 补丁应远小于原文件。
// 这里用 5% 作宽松上限(块粒度 4KB,改动分散在 100 字节间隔上,
// 会打断一些块,实际比例低于此)。
if st.Ratio > 0.05 {
t.Errorf("★ 补丁过大(%.2f%% of %d bytes):差分没起作用", st.Ratio*100, hdr.NewSize)
}
}
// ③ 基线不符必须拒绝(用错旧版本 ⇒ 产出垃圾)。
func TestDeltaRejectsWrongBaseline(t *testing.T) {
old := []byte("the-original-content-here")
newData := []byte("the-original-content-here-plus-more")
patch, _, err := MakeDelta(old, newData)
if err != nil {
t.Fatal(err)
}
if _, err := ApplyDelta([]byte("a-completely-different-baseline"), patch); err == nil {
t.Fatal("★ 基线不符时必须拒绝(否则静默产出垃圾)")
}
}
// ④ 补丁被截断必须拒绝,不得"部分成功"。
func TestDeltaRejectsTruncatedPatch(t *testing.T) {
old := bytes.Repeat([]byte("Z"), 8192)
newData := append([]byte(nil), old...)
newData = append(newData, []byte("TAIL")...)
patch, _, err := MakeDelta(old, newData)
if err != nil {
t.Fatal(err)
}
for _, cut := range []int{len(deltaMagic), 20, len(patch) / 2, len(patch) - 1} {
if _, err := ApplyDelta(old, patch[:cut]); err == nil {
t.Errorf("★ 截断到 %d/%d 字节仍被接受", cut, len(patch))
}
}
}
// ⑤ 内容被篡改必须被 sha256 咬住。
//
// 这是最后一道防线:patch 结构合法、指令也不越界,但产出内容错。
func TestDeltaRejectsCorruptedOutput(t *testing.T) {
old := bytes.Repeat([]byte("Q"), 4096)
newData := append([]byte(nil), old...)
newData[0] = 'X'
patch, hdr, err := MakeDelta(old, newData)
if err != nil {
t.Fatal(err)
}
// 篡改头部里的 NewSHA256(模拟恶意/损坏的补丁)
b := append([]byte(nil), patch...)
off := len(deltaMagic) + 16 + 64
b[off] ^= 0xFF
if _, err := ApplyDelta(old, b); err == nil {
t.Fatal("★ 内容哈希不符时必须拒绝")
}
_ = hdr
}
// ⑥ 空文件与超小文件不崩。
func TestDeltaEdgeCases(t *testing.T) {
cases := [][2][]byte{
{nil, nil},
{[]byte("a"), []byte("b")},
{bytes.Repeat([]byte("a"), 100), bytes.Repeat([]byte("a"), 100)},
{[]byte{}, []byte("new")},
{[]byte("old"), []byte{}},
}
for i, c := range cases {
patch, _, err := MakeDelta(c[0], c[1])
if err != nil {
t.Fatalf("case %d 生成失败: %v", i, err)
}
got, err := ApplyDelta(c[0], patch)
if err != nil {
t.Fatalf("case %d 应用失败: %v", i, err)
}
if !bytes.Equal(got, c[1]) {
t.Errorf("case %d 结果不符", i)
}
}
}
// ⑦ 文件级入口(CLI 用)。
func TestDeltaFileRoundTrip(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "homed.old")
newPath := filepath.Join(dir, "homed.new")
patchPath := filepath.Join(dir, "homed.patch")
outPath := filepath.Join(dir, "homed.out")
oldData := bytes.Repeat([]byte("0123456789abcdef"), 4096)
newData := append([]byte(nil), oldData...)
newData = append(newData[:100], append([]byte("INSERTED"), newData[100:]...)...)
if err := os.WriteFile(oldPath, oldData, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(newPath, newData, 0755); err != nil {
t.Fatal(err)
}
hdr, patchSize, err := MakeDeltaFile(oldPath, newPath, patchPath)
if err != nil {
t.Fatal(err)
}
t.Logf("patch=%d bytes (%.2f%% of new)", patchSize, hdr.Stats(patchSize).Ratio*100)
if _, err := ApplyDeltaFile(oldPath, patchPath, outPath); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(outPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, newData) {
t.Fatal("★ 文件级往返结果不一致")
}
}
// ⑧ 头部解析拒绝非本格式。
func TestParseDeltaHeaderRejectsGarbage(t *testing.T) {
if _, err := ParseDeltaHeader([]byte("not a delta file at all")); err == nil {
t.Fatal("应拒绝非补丁数据")
}
}

180
internal/update/executor.go Normal file
View File

@ -0,0 +1,180 @@
package update
// 内核侧的**升级执行者**:订阅 update_request 并真正执行升级。
//
// ## 为什么执行权必须在内核,而不是某个插件
//
// 升级会替换内核二进制并让进程消失。这个动作的执行者必须满足三条:
//
// ① 不会被用户禁用(插件可以 auto_start:false / disable)
// ② 不依赖插件进程活着(插件崩了请求就丢了)
// ③ 知道自己的 dataDir / binPath(内核才有 ks.DataDir 这个权威值)
//
// 只有内核模块同时满足。而 pluginmgr 作为插件,三条全不满足。
//
// ## 为什么是「延迟挂载」而不是在 Init 里订阅
//
// update 模块必须在**第 0 层**(回滚判定要早于一切模块),但事件总线
// 由 kernelapi 层产出 —— 第 0 层时它还不存在。
//
// 所以分两段:
//
// Init(第 0 层) : 回滚判定 + 能力探测(不需要总线)
// Attach(总线就绪后): 订阅 update_request,接管执行
//
// Attach 由调用方(cmd/homed)在 kernelapi 层完成后调用。它幂等:
// 重复调用不会重复订阅。
import (
"fmt"
"log"
"os"
"sync"
"github.com/JianFeeeee/HologramAgent/internal/events"
)
// executor 是内核侧的升级执行器实例(进程级单例)。
type executor struct {
mu sync.Mutex
attached bool
unsub func()
// running 防止并发升级:两个请求同时进来会互相覆盖备份目录、
// 交错替换 homed 与 depgraph。(旧实现完全没有互斥。)
running bool
}
var kernelExec = &executor{}
// AttachExecutor 挂载内核侧升级执行器(幂等)。
//
// bus 为空时什么也不做(未接总线的宿主,如部分单测)。
// 返回的 cleanup 用于关停时退订。
func AttachExecutor(bus *events.Bus, dataDir string) func() {
if bus == nil {
return func() {}
}
kernelExec.mu.Lock()
if kernelExec.attached {
kernelExec.mu.Unlock()
return func() {}
}
kernelExec.attached = true
kernelExec.unsub = bus.Subscribe(events.EventUpdateRequest, func(evt *events.Event) {
handleUpdateRequest(evt, dataDir, bus)
})
kernelExec.mu.Unlock()
log.Printf("[update] kernel-side upgrade executor attached (data_dir=%s)", dataDir)
return func() {
kernelExec.mu.Lock()
defer kernelExec.mu.Unlock()
if kernelExec.unsub != nil {
kernelExec.unsub()
kernelExec.unsub = nil
}
kernelExec.attached = false
}
}
// handleUpdateRequest 处理一条 update_request。
//
// ## 语义分工
//
// scope=kernel → **本模块**执行(内核升级是内核的职责)
// scope=plugin → 交给插件通道(pluginmgr 自己订阅处理)
//
// 内核侧只接管 kernel,不越界去管插件 —— 插件升级要用 hmapdev 现场编译,
// 那是应用域的事。
//
// ## 为什么异步
//
// 升级会替换二进制并请求重启,本进程随后消失。事件处理器是**同步投递**的
// (events.Bus.Publish 直接调 handler),在处理器里同步做完会让发布方卡住
// 直到进程被杀。
func handleUpdateRequest(evt *events.Event, dataDir string, bus *events.Bus) {
if evt == nil || evt.Payload == nil {
return
}
scope, _ := evt.Payload["scope"].(string)
if scope != "kernel" {
return // 插件升级不归内核执行器
}
pkgPath, _ := evt.Payload["pkg"].(string)
release, _ := evt.Payload["release"].(bool)
kernelExec.mu.Lock()
if kernelExec.running {
kernelExec.mu.Unlock()
log.Printf("[update] 拒绝并发升级请求(来源 %s):已有升级在进行中", evt.Source)
publishResult(bus, "kernel", "", false, "", "", false,
"已有升级正在进行中,本次请求被拒绝(避免备份与替换交错)")
return
}
kernelExec.running = true
kernelExec.mu.Unlock()
go func() {
defer func() {
kernelExec.mu.Lock()
kernelExec.running = false
kernelExec.mu.Unlock()
}()
if err := RunUpgrade(pkgPath, release, dataDir, bus); err != nil {
log.Printf("[update] upgrade failed (source=%s): %v", evt.Source, err)
}
}()
}
// RunUpgrade 执行一次内核升级(内核侧入口,含形态分派)。
//
// 流程:解析来源 → 形态闸门 → 取包 → Apply(写 pending + 原子替换)。
// **不重启**:重启由调用方走 KernelControlAPI(restartCh → exit 42)。
func RunUpgrade(pkgPath string, release bool, dataDir string, bus *events.Bus) error {
src, err := ParseSource(pkgPath, release)
if err != nil {
publishResult(bus, "kernel", "", false, "", "", false, err.Error())
return err
}
bus = eventBusOr(bus)
binPath := KernelBinaryPath()
// ★ 形态闸门:先判断能不能自己升,再决定要不要下载 880MB。
capab := Detect(binPath, dataDir)
plan := PlanUpgrade(capab, pkgPath)
if !plan.Executable {
publishResult(bus, "kernel", "", false, "", "", false, plan.Reason)
return &NotExecutableError{Plan: plan}
}
workDir, err := os.MkdirTemp("", "kernel-upgrade-*")
if err != nil {
publishResult(bus, "kernel", "", false, "", "", false, err.Error())
return fmt.Errorf("建临时目录: %w", err)
}
defer os.RemoveAll(workDir)
root, _, err := src.Resolve(workDir)
if err != nil {
publishResult(bus, "kernel", "", false, "", "", false, err.Error())
return err
}
newDG := FindFile(root, "depgraph.bin")
_, err = Apply(root, binPath, newDG, dataDir, bus)
return err
}
// NotExecutableError 表示本机形态不允许自替换(调用方应转成可执行提示)。
type NotExecutableError struct {
Plan UpgradePlan
}
func (e *NotExecutableError) Error() string {
if len(e.Plan.Commands) > 0 {
return e.Plan.Reason + ";请执行: " + e.Plan.Commands[0]
}
return e.Plan.Reason
}

View File

@ -0,0 +1,160 @@
package update
// 内核侧升级执行器的判据。
//
// 这些用例钉住的是「谁执行」与「能不能并发」——两者错了的后果都比较重:
// 前者会让升级依赖一个可被禁用的插件,后者会让两次升级交错替换文件。
import (
"os"
"path/filepath"
"sync"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/events"
)
// ① AttachExecutor 幂等:重复挂载不重复订阅。
func TestAttachExecutor_Idempotent(t *testing.T) {
bus := events.NewBus()
c1 := AttachExecutor(bus, t.TempDir())
c2 := AttachExecutor(bus, t.TempDir())
if c1 == nil || c2 == nil {
t.Fatal("应返回非 nil 的 cleanup")
}
// 第二次应返回空操作,并且不应再订阅一次
kernelExec.mu.Lock()
attached := kernelExec.attached
kernelExec.mu.Unlock()
if !attached {
t.Error("应处于已挂载状态")
}
c1()
c2()
}
// ② bus 为 nil 时不 panic(未接总线的宿主)。
func TestAttachExecutor_NilBus(t *testing.T) {
c := AttachExecutor(nil, t.TempDir())
if c == nil {
t.Fatal("应返回可调用的 cleanup 而不是 nil")
}
c() // 不应 panic
}
// ③ scope=plugin 的请求**不被内核执行器接管**。
//
// 这是职责边界:插件升级要 hmapdev 现场编译,那是应用域的事。
// 内核越界去管会让两条通道互相打架。
func TestHandleUpdateRequest_IgnoresPluginScope(t *testing.T) {
bus := events.NewBus()
// 清掉可能残留的挂载状态
kernelExec.mu.Lock()
kernelExec.running = false
kernelExec.mu.Unlock()
handleUpdateRequest(&events.Event{
Type: events.EventUpdateRequest,
Source: "test",
Payload: map[string]interface{}{"scope": "plugin", "name": "timer"},
}, t.TempDir(), bus)
kernelExec.mu.Lock()
running := kernelExec.running
kernelExec.mu.Unlock()
if running {
t.Error("★ scope=plugin 不应被内核执行器接管")
}
}
// ④ 并发请求只允许一个通过(旧实现完全没有互斥)。
//
// 判据不是"返回了错误",而是**第二个请求确实没有开始执行**:
// 两个升级同时跑会互相覆盖备份目录、交错替换 homed 与 depgraph。
func TestHandleUpdateRequest_RejectsConcurrent(t *testing.T) {
bus := events.NewBus()
// 手工占住 running,模拟"升级进行中"
kernelExec.mu.Lock()
kernelExec.running = true
kernelExec.mu.Unlock()
t.Cleanup(func() {
kernelExec.mu.Lock()
kernelExec.running = false
kernelExec.mu.Unlock()
})
// 发一个 kernel 请求:应被拒绝且**不启动 goroutine**
handleUpdateRequest(&events.Event{
Type: events.EventUpdateRequest,
Source: "agent",
Payload: map[string]interface{}{"scope": "kernel", "release": true},
}, t.TempDir(), bus)
// running 仍是 true(被拒绝的请求没有改动它)
kernelExec.mu.Lock()
stillRunning := kernelExec.running
kernelExec.mu.Unlock()
if !stillRunning {
t.Error("被拒绝的并发请求不应改变 running 标志")
}
}
// ⑤ RunUpgrade 在形态不允许自替换时返回 NotExecutableError(带可执行命令)。
//
// 这条保证:请求方拿到的是「该走哪条通道」而不是一个裸 error。
func TestRunUpgrade_NonExecutableReturnsPlan(t *testing.T) {
// 造一个"不可写"的环境不可靠(root 无视权限),所以直接构造
// 一个自替换被禁的 Capabilities 路径:用容器形态的判据。
// 这里改为验证 NotExecutableError 本身的语义,不依赖运行环境。
p := UpgradePlan{
Channel: ChannelPackageManager,
Reason: "由 dpkg 安装",
Commands: []string{"sudo apt install --only-upgrade hologramagent-full"},
}
err := &NotExecutableError{Plan: p}
msg := err.Error()
if msg == "" {
t.Fatal("错误消息不能为空")
}
if got := msg; !contains(got, "sudo apt") {
t.Errorf("应包含可执行命令,实际: %s", got)
}
}
func contains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && indexOfStr(s, sub) >= 0)
}
func indexOfStr(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
// ⑥ 大量并发调用不产生数据竞争(配合 -race)。
func TestHandleUpdateRequest_NoRaceOnRunning(t *testing.T) {
bus := events.NewBus()
kernelExec.mu.Lock()
kernelExec.running = false
kernelExec.mu.Unlock()
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
handleUpdateRequest(&events.Event{
Type: events.EventUpdateRequest,
Source: "race",
Payload: map[string]interface{}{"scope": "plugin"}, // 不触发执行
}, t.TempDir(), bus)
}()
}
wg.Wait()
_ = os.Getpid
_ = filepath.Join
}

145
internal/update/fsutil.go Normal file
View File

@ -0,0 +1,145 @@
package update
// 文件写入的**唯一**原语。
//
// ## 为什么必须收敛到这里
//
// 全仓曾经有 5 处各自实现的「写临时文件 → rename」:
//
// internal/memory/pipeline/pipeline.go writeFileAtomic
// internal/security/store.go 写密钥库
// internal/update/release.go downloadFile
// internal/update/kernel.go CopyFile
// internal/update/rollback.go (PR 前是直接 os.WriteFile)
//
// 它们的行为不一致,而且**只有一处**在 rename 前 fsync。不一致的方向
// 恰恰是危险的那一侧:没有 fsync 的 rename 在掉电后可能留下**零长度**
// 或**半截**的目标文件 —— rename 的原子性只保证"名字指向谁",不保证
// "内容已落盘"。
//
// 升级路径上这个问题不可接受:homed 被替换成半截文件 ⇒ 内核起不来,
// 而 pending 标记可能已经清了 ⇒ 无法回滚。
//
// ## 三条不变量
//
// 1. 目标路径在任何时刻要么是**完整的旧内容**、要么是**完整的新内容**
// 2. 返回 nil 意味着内容**已落盘**(fsync 过),不只是交给了页缓存
// 3. 失败时**不动**目标文件(临时文件被清掉)
import (
"fmt"
"io"
"os"
"path/filepath"
)
// 临时文件后缀。刻意区分于 Apply 的 ".tmp-upgrade":
// 那个是旧实现的遗留命名,新代码统一用 WriteFileAtomic/CopyFileAtomic。
const atomicTmpSuffix = ".tmp-atomic"
// WriteFileAtomic 原子地写入文件(临时文件 → fsync → rename → fsync 目录)。
//
// perm 只在新文件创建时生效;目标已存在时保留其原有权限位
// (升级替换 homed 时不能因为传了 0644 就把可执行位丢掉)。
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("建目录 %s: %w", dir, err)
}
mode := perm
if st, err := os.Stat(path); err == nil {
mode = st.Mode().Perm()
}
tmp := path + atomicTmpSuffix
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return fmt.Errorf("建临时文件 %s: %w", tmp, err)
}
cleanup := func() {
f.Close()
os.Remove(tmp)
}
if _, err := f.Write(data); err != nil {
cleanup()
return fmt.Errorf("写 %s: %w", tmp, err)
}
// ★ fsync 必须在 Close 之前:Close 只把数据交给内核页缓存。
if err := f.Sync(); err != nil {
cleanup()
return fmt.Errorf("fsync %s: %w", tmp, err)
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return fmt.Errorf("close %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename %s → %s: %w", tmp, path, err)
}
syncDir(dir)
return nil
}
// CopyFileAtomic 原子地复制文件(用于替换可执行文件)。
//
// 与 WriteFileAtomic 的差别只有来源:这里流式拷贝,避免把 88MB 的
// 二进制整个读进内存。
//
// ★ 权限位取**源文件**的(升级包里的 homed 是 0755,必须保留可执行位)。
func CopyFileAtomic(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
st, err := in.Stat()
if err != nil {
return err
}
dir := filepath.Dir(dst)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("建目录 %s: %w", dir, err)
}
tmp := dst + atomicTmpSuffix
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, st.Mode().Perm())
if err != nil {
return fmt.Errorf("建临时文件 %s: %w", tmp, err)
}
cleanup := func() {
out.Close()
os.Remove(tmp)
}
if _, err := io.Copy(out, in); err != nil {
cleanup()
return fmt.Errorf("拷贝 %s → %s: %w", src, tmp, err)
}
if err := out.Sync(); err != nil {
cleanup()
return fmt.Errorf("fsync %s: %w", tmp, err)
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return fmt.Errorf("close %s: %w", tmp, err)
}
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename %s → %s: %w", tmp, dst, err)
}
syncDir(dir)
return nil
}
// syncDir 让 rename 本身落盘。
//
// ★ 失败只忽略,不报错:部分文件系统(overlay/tmpfs/容器层)对目录
// fsync 返回 EINVAL。此时 rename 已完成、内容已正确,为此让升级失败
// 只会把可用环境弄成不可用。方向是安全的:最坏情况是掉电后连 rename
// 都没生效,磁盘上留的仍是**完整的旧文件**。
func syncDir(dir string) {
d, err := os.Open(dir)
if err != nil {
return
}
defer d.Close()
_ = d.Sync()
}

141
internal/update/gc.go Normal file
View File

@ -0,0 +1,141 @@
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)
}
}

548
internal/update/install.go Normal file
View File

@ -0,0 +1,548 @@
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
}

View File

@ -35,12 +35,17 @@ type BinVersion struct {
SDKVersion string
}
// readBinVersion 跑 `<bin> --version` 并解析。
// ReadBinVersion 跑 `<bin> --version` 并解析(含老版本回退,用于**探测**)。
//
// ★ 为什么用"跑它自己"而不是读文件里的字符串:
//
// 版本是 ldflags 注入的,从 ELF 里 grep 只能撞运气;而 --version 是内核
// 自己算出来的,还能一并拿到 commit("同版本号不同 commit"很难察觉)。
//
// ★★ 注意:这个回退使 ReadBinVersion **不能**用作回滚校验。推断出来的
// 版本可能来自**另一个文件**(或运行中的老进程),拿它当“刚拷过去的
// 文件能执行”的证词,会让一个零字节的坏备份“校验通过”。回滚路径用
// digest(见 rollback.go 的 verifyRestored),不用版本。
func ReadBinVersion(bin string) (*BinVersion, error) {
if _, err := os.Stat(bin); err != nil {
return nil, err
@ -174,6 +179,54 @@ func ShortCommit(c string) string {
return c
}
// readBinVersionStrict 只认**这个文件自己**能报出版本,任何回退都不要。
//
// 与 ReadBinVersion 的唯一区别:exec 失败直接返回 error,**不**走
// InferVersionFromRunning。用于“确认分支”这类必须证明“这个文件能用”
// 的判定。
//
// ★ 不要拿它当回滚校验:老内核(1.3.x 及以前)没有 --version,
// 而它们恰恰是最常见的回滚目标。回滚用 digest(verifyRestored)。
func readBinVersionStrict(bin string) (*BinVersion, error) {
if _, err := os.Stat(bin); err != nil {
return nil, err
}
out, err := exec.Command(bin, "--version").CombinedOutput()
if err != nil {
return nil, fmt.Errorf("执行 --version: %v(该文件不可执行或架构不符)", err)
}
v := &BinVersion{}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
k, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "version":
v.Version = val
case "commit":
v.Commit = val
case "build_time":
v.BuildTime = val
case "sdk":
v.SDKVersion = val
}
}
if v.Version == "" {
return nil, fmt.Errorf("--version 输出里没有 version= 行: %q", strings.TrimSpace(string(out)))
}
return v, nil
}
// ShortSum 把 sha256 缩到前 12 位,用于错误消息(完整值对排查无益)。
func ShortSum(s string) string {
if len(s) > 12 {
return s[:12]
}
return s
}
// extractPkg 解包并返回包含 bin/ 的根目录。
func ExtractPkg(pkg, dst string) (string, error) {
f, err := os.Open(pkg)
@ -264,14 +317,44 @@ func FileSize(p string) int64 {
// 所以这里只承诺到"磁盘上的产物已就绪,pending 标记已写"。
// 后续重启 → 新内核启动 → update 模块(第 0 层)确认或回滚。
//
// 每一步失败都往回退:替换中途失败 ⇒ 立即用备份还原并清标记
// (此时尚未重启,回滚在本进程内完成,机器始终可启动)。
// ## 每一步都是可撤销的(顺序不可换)
//
// 预检 → 备份 → 记录 digest → 写 pending → 替换 depgraph → 替换 homed
//
// 任一步失败就地回滚,且**回滚完整性本身就是一种可验证的事实**:
// 每个文件都记了 sha256,还原后逐一校对;只有全部对上才清 marker。
// 这不是“多抄一遍”,“重新读版本”在旧内核上根本做不到 —— 旧内核没有
// `--version`,而旧内核恰恰是最常见的回滚目标。
func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, error) {
// ★ 调用方可以传 nil:内核在启动时 SetEventBus 注入过总线,
// 这里取回那份(插件不再需要持有 *events.Bus)。
bus = eventBusOr(bus)
newBin := FindFile(root, "homed")
if newBin == "" {
return nil, fmt.Errorf("包内无 homed")
}
// ── ★ 最前置的拒绝条件:没有 dataDir 就没有回滚能力 ──
//
// pending 标记、备份目录、坏二进制留档全落在 dataDir 下。空 dataDir
// 意味着标记写不进去 ⇒ 新内核起不来时没有任何人知道该回滚。
// 这种升级不是升级,是赌运气,赌输的代价是机器进崩溃循环。
if dataDir == "" {
return nil, fmt.Errorf("dataDir 为空,拒绝升级:无 pending 标记就无回滚能力")
}
// ── ★ 安装形态闸门:包管理器拥有的文件不允许直接替换 ──
//
// 「能写」不等于「该写」。dpkg/rpm 记录了受管文件的校验和,绕过它
// 替换的后果是下次 apt upgrade 覆盖回旧版,或 dpkg 拒绡后续升级。
capab := Detect(binPath, dataDir)
if !capab.CanWriteBin {
return nil, fmt.Errorf("本机不允许直接替换二进制(%s/%s):%s\n"+
"建议改用包管理器升级:%s",
capab.Kind, capab.Variant, capab.Reason, capab.UpgradeCommand(""))
}
// ── 预检:新二进制必须能自报版本(说明它是可执行的、架构对的)──
nv, err := ReadBinVersion(newBin)
if err != nil {
@ -280,18 +363,36 @@ func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, err
publishProgress(bus, "kernel", "", "verify", "新内核可执行,版本 "+nv.Version)
fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version)
// ── 备份:只备本次会动的文件(不整目录拷贝,那是几百 MB)──
// ── 备份:只备本次会动的文件(不整目录拷贝,那是个几百 MB)──
//
// ★ 备份必须落在 dataDir 下,不能是 /root:服务以 ProtectSystem=strict
// + 非 root 运行,对 /root 没有写权限 ⇒ MkdirAll 必 EACCES。
ts := NowStamp()
backup := filepath.Join("/root", "homed-upgrade-backup-"+ts)
backup := filepath.Join(dataDir, BackupDirPrefix+ts)
if err := os.MkdirAll(backup, 0755); err != nil {
return nil, fmt.Errorf("建备份目录: %w", err)
}
// targets 是本轮会动的**全部**文件。每个都记录 digest —— 回滚时那
// 一组 digest 就是“已完整还原”的判据。
targets := []string{binPath}
if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); FileExists(dg) {
targets = append(targets, dg)
dgPath := filepath.Join(filepath.Dir(binPath), "depgraph.bin")
// ★ hadDepGraph 必须在**备份之前**取:若原本没有、而新包带了,
// 回滚时必须把它删掉。留着会让旧 homed 的内嵌 digest 对不上,
// 而那时 marker 已清 ⇒ 进崩溃循环且无第二次自愈。
hadDepGraph := FileExists(dgPath)
if hadDepGraph {
targets = append(targets, dgPath)
}
digests := make(map[string]string, len(targets))
for _, t := range targets {
if err := CopyFile(t, filepath.Join(backup, filepath.Base(t))); err != nil {
sum, serr := sha256File(t)
if serr != nil {
return nil, fmt.Errorf("计算 %s 的 digest: %w", t, serr)
}
digests[t] = sum
if err := CopyFileAtomic(t, filepath.Join(backup, filepath.Base(t))); err != nil {
return nil, fmt.Errorf("备份 %s: %w", t, err)
}
}
@ -300,62 +401,104 @@ func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, err
// ── 写待确认升级标记(在替换**之前**)──
//
// 顺序很关键:先写标记再替换。若反过来(替换后才写),"替换成功但
// 没来得及写标记就崩了"会丢标记 ⇒ 下次启动不知道该回滚。
// 顺序很关键:先写标记再替换。若反过来(替换后才写),“替换成功但
// 没来得及写标记就崩了”会丢标记 ⇒ 下次启动不知道该回滚。
curVer := &BinVersion{}
if v, verr := ReadBinVersion(binPath); verr == nil {
curVer = v
}
if dataDir != "" {
if perr := MarkPendingUpgrade(dataDir, PendingRecord{
FromVer: curVer.Version,
FromCommit: curVer.Commit,
ToVer: nv.Version,
ToCommit: nv.Commit,
BackupDir: backup,
}); perr != nil {
// 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力,
// 而不带回滚的升级等于赌运气。
return nil, fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr)
}
fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir))
if perr := MarkPendingUpgrade(dataDir, PendingRecord{
FromVer: curVer.Version,
FromCommit: curVer.Commit,
ToVer: nv.Version,
ToCommit: nv.Commit,
BackupDir: backup,
HadDepGraph: hadDepGraph,
Digests: digests,
}); perr != nil {
// 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力。
return nil, fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr)
}
fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir))
// replaceState 记录“替换阶段已经动过哪些文件(以及新包是否带了 depgraph)”。
//
// ★ 为什么不用 targets/resources 推断:旧实现直接用 targets 还原,于是
// “新包带了 depgraph、原机器没有”这种情况会多出一条无源可还原的项,
// 以及“原本有、新包没带”会被误删。动作记录才是事实。
var touchedDepGraph bool
// restore 在替换失败时把已动的文件还原(尚未重启,机器仍可启动)。
//
// ★ restore 完整才清 marker:旧实现先清 marker 再还原,于是“还原也
// 失败”时磁盘上可能是半新半旧、而 marker 已经没了 ⇒ 失去第二次自愈。
restore := func(cause error) (*Outcome, error) {
publishProgress(bus, "kernel", "", "rollback", cause.Error())
fmt.Printf("\n❌ %v\n== 就地还原(尚未重启,机器仍可启动)==\n", cause)
if dataDir != "" {
if cerr := ClearPendingUpgrade(dataDir); cerr != nil {
fmt.Printf(" ⚠️ 清除升级确认标记失败: %v\n", cerr)
}
}
var failed []string
for _, t := range targets {
src := filepath.Join(backup, filepath.Base(t))
if err := CopyFile(src, t); err != nil {
fmt.Printf(" ⚠️ 还原 %s 失败: %v(需人工处理)\n", t, err)
// ★ 用 digest 判“已还原”:比“CopyFile 没报错”强。
// CopyFileAtomic 成功只说明字节搬完了,不说明内容与当时一致
// (备份本身可能已损坏)。
if err := CopyFileAtomic(src, t); err != nil {
failed = append(failed, fmt.Sprintf("%s: %v", t, err))
continue
}
fmt.Printf(" 已恢复 %s\n", t)
if got, serr := sha256File(t); serr != nil || got != digests[t] {
failed = append(failed, fmt.Sprintf("%s: digest 不符", t))
continue
}
fmt.Printf(" 已恢复并校验 %s\n", t)
}
// 新包装了 depgraph 而原机器没有 ⇒ 回滚时必须删掉它。
if touchedDepGraph && !hadDepGraph {
if err := os.Remove(dgPath); err != nil && !os.IsNotExist(err) {
failed = append(failed, fmt.Sprintf("%s: 删除失败 %v", dgPath, err))
} else {
fmt.Printf(" 已移除新装上的 %s(原机器没有)\n", dgPath)
}
}
if len(failed) > 0 {
// ★ marker 保留:磁盘状态不确定,保留才能让下次启动再试。
publishResult(bus, "kernel", "", false, curVer.Version, nv.Version, false,
"替换失败且就地还原不完整: "+strings.Join(failed, "; "))
return nil, fmt.Errorf("替换失败且就地还原不完整(pending 标记已保留,下次启动会再试): %w\n 未还原: %s",
cause, strings.Join(failed, "; "))
}
if cerr := ClearPendingUpgrade(dataDir); cerr != nil {
fmt.Printf(" ⚠️ 清除升级确认标记失败: %v\n", cerr)
}
publishResult(bus, "kernel", "", false, curVer.Version, nv.Version, true, cause.Error())
return nil, fmt.Errorf("替换失败已还原(未重启): %w", cause)
return nil, fmt.Errorf("替换失败已完整还原(未重启,机器仍可启动): %w", cause)
}
// ── 替换(depgraph 必须与 homed 同批,顺序无关但都要成)──
if newDG != "" {
dst := filepath.Join(filepath.Dir(binPath), "depgraph.bin")
if err := CopyFile(newDG, dst); err != nil {
touchedDepGraph = true
if err := CopyFileAtomic(newDG, dgPath); err != nil {
return restore(fmt.Errorf("替换 depgraph.bin: %w", err))
}
fmt.Println("已替换 depgraph.bin")
}
if err := CopyFile(newBin, binPath); err != nil {
if err := CopyFileAtomic(newBin, binPath); err != nil {
return restore(fmt.Errorf("替换 homed: %w", err))
}
fmt.Println("已替换 homed")
publishProgress(bus, "kernel", "", "ready", "新内核已就位,等待重启")
// ★ 成功路径也必须发 result 事件:
// 此前只有失败路径发,订阅方只能靠“进度停在最后一步且没看到 failed”
// 来猜成功,猜错的方向是“以为还在升级”。先发事件再返回:返回后
// 调用方可能立即重启,进程消失。
publishResult(bus, "kernel", "", true, curVer.Version, nv.Version, false, "")
// 备份必须 GC:旧注释写“确认稳定后可删”,但全仓从来没有一行删除逻辑。
PruneOldBackups(dataDir)
return &Outcome{
From: curVer,
To: nv,
@ -370,33 +513,13 @@ type Outcome struct {
BackupDir string // 备份目录(确认稳定后可删)
}
func CopyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
st, err := in.Stat()
if err != nil {
return err
}
tmp := dst + ".tmp-upgrade"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, st.Mode())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
// 原子替换:先写临时文件再 rename,避免"写到一半进程在跑"
return os.Rename(tmp, dst)
}
// CopyFile 是**向后兼容的别名**,内部走 CopyFileAtomic(含 fsync)。
//
// ★ 保留它而不是删:旧调用点(含测试)仍在用这个名字,而旧实现在
//
// rename 前**没有 fsync** —— 掉电后会留下半截文件。统一到原子实现,
// 调用方无需改一行。
func CopyFile(src, dst string) error { return CopyFileAtomic(src, dst) }
func FileExists(p string) bool {
_, err := os.Stat(p)

View File

@ -68,8 +68,20 @@ type ReleaseInfo struct {
Asset string `json:"asset,omitempty"` // 匹配到的资产名(tar.gz)
AssetURL string `json:"asset_url,omitempty"` // 资产下载地址
AssetDigest string `json:"asset_digest,omitempty"` // GitHub SHA-256 digest
HasUpdate bool `json:"has_update"`
Reason string `json:"reason,omitempty"` // 查不到时的说明
// DeltaAsset / DeltaURL 是本版针对**当前本机版本**的差分资产(可选)。
//
// ★ 为何单独一字段而不是复用 Asset:
// 整包与 delta 是两条不同的取包路径,一条失败能退回另一条。
// 混在一个字段里就无法表达“有整包、但 delta 不适用”。
//
// 空串 = 该 release 没有为当前版本提供 delta(首次安装、跳版本、
// 或发布时判定不适合差分)⇒ 调用方直接走整包。
DeltaAsset string `json:"delta_asset,omitempty"`
DeltaURL string `json:"delta_url,omitempty"`
HasUpdate bool `json:"has_update"`
Reason string `json:"reason,omitempty"` // 查不到时的说明
}
// CheckRelease 查 GitHub Release 并与本机版本比较。
@ -114,6 +126,24 @@ func CheckRelease(current *BinVersion) *ReleaseInfo {
}
}
// 找针对**当前本机 commit** 的 delta(可选)。
//
// 命名约定:...-from-<oldCommit8>.delta。带上来源 commit 而不是
// “随便挑一个 delta”:差分只能对特定旧版本应用,挑错基线会静默产出一份
// 看起来正常的垃圾(大小结构都对、就是内容错)。
//
// 找不到就不填 —— 调用方直接走整包,不是错误。
if info.CurrentC != "" && info.CurrentC != "unknown" {
want := "from-" + ShortCommit(info.CurrentC) + ".delta"
for _, a := range rel.Assets {
if strings.HasSuffix(a.Name, want) {
info.DeltaAsset = a.Name
info.DeltaURL = a.BrowserDownloadURL
break
}
}
}
if current == nil || current.Version == "" {
info.Reason = "本机版本未知(内核过老,无 --version):无法比较,请人工确认"
return info

View File

@ -36,6 +36,7 @@ import (
"log"
"os"
"path/filepath"
"strings"
"time"
)
@ -50,8 +51,30 @@ type PendingRecord struct {
// ToVer / ToCommit 是升级**后**的版本(当前正在试的)。
ToVer string `json:"to_version"`
ToCommit string `json:"to_commit"`
// BackupDir 是升级前备份目录(DoUpgrade 创建),回滚从这里取二进制。
// BackupDir 是升级前备份目录(Apply 创建),回滚从这里取二进制。
BackupDir string `json:"backup_dir"`
// HadDepGraph 记录升级**前**机器上是否存在 depgraph.bin。
//
// ★ 为什么必须记(这是一个真实的崩溃循环):新包带了 depgraph、而原机器
// 没有时,回滚只把 homed 换回旧版、不动 depgraph ⇒ 磁盘上是
// 「旧 homed + 新 depgraph」。旧 homed 内嵌的 digest 对不上,
// kernelboot 装载期直接拒绕启动,而 marker 此时已被清 ⇒
// **既起不来也回滚不了**。零值 false 与“旧 marker 无此字段”天然兼容。
HadDepGraph bool `json:"had_depgraph"`
// Digests 是升级前各目标文件的 sha256(key 为**绝对路径**)。
//
// ★ 为什么不能用 `--version` 当回滚判据:旧内核(1.3.x 及以前)
// **没有 --version flag**,而旧内核恰恰是最常见的回滚目标。
// 强行要求它自报版本 = 从旧版本升级失败后永远回滚不了(strict
// 回读失败 → 拒绝启动 → 下次启动仍同一条路径)。
// digest 是“恢复的是当时那份文件”的证据,与二进制支不支持 --version 无关。
//
// 兼容性:缺失时回退到“能执行 + 版本相等”(旧逻辑),
// 新写的人一定带它;老 marker 也能继续工作。
Digests map[string]string `json:"digests,omitempty"`
// At 是写入时间(诊断用)。
At time.Time `json:"at"`
}
@ -131,18 +154,27 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error
//
// 这一步是必须的,而且顺序不能反(2026-10-26 真机实测踩到):
// 标记是在**替换之前**写的,所以升级后的**第一次启动**必然看到它。
// 若不比对版本就直接回滚,那么每次升级都会在第一次启动时被立刻撤销 ——
// 实测表现:`-apply` 报"升级完成",重启后日志却是
// "ROLLED BACK kernel 1.4.0dev → 1.4.0dev(上一次升级未能确认启动)",
// 也就是升级永远不可能生效。
// 若不比对版本就直接回滚,那么每次升级都会在第一次启动时被立刻撤销。
//
// 语义区分(这才是两段式确认的本意):
//
// 当前版本 == 标记里的 To ⇒ 新版本**已经跑起来了** ⇒ 确认,清标记,继续启动
// 当前版本 != To(或读不到)⇒ 新版本没能成为运行版本(exec 失败、被 guard 回退等)
// ⇒ 回滚到备份
//
// ★★★ 确认分支**必须**用严格版本(不可用带推断回退的 ReadBinVersion)。
//
// 这里曾经用 ReadBinVersion,于是与回滚校验是同一个病:它在 exec 失败时会
// 回退到 InferVersionFromRunning()(读 /usr/local/bin/homed 的字符串、或跑
// /usr/local/bin/waiter --version)。已实测的事故形态:binPath 是一个
// 不可执行的残文件,marker.ToVer 恰好等于本机 waiter 的版本 ⇒ 推断回退从
// **waiter** 借到相同版本 ⇒ 与 ToVer 相等 ⇒ confirmed=true ⇒ 清 marker、
// **不回滚** ⇒ 机器带着跑不起来的二进制继续启动,且失去自愈机会。
//
// 判据只有一条:这个文件自己能不能报出版本。报不出 ⇒ confirmed=false
// ⇒ 走回滚。绝不用推断值去证明"新版已跑起来"。
confirmed := false
if cur, cerr := ReadBinVersion(binPath); cerr == nil {
if cur, cerr := readBinVersionStrict(binPath); cerr == nil {
sameVer := cur.Version == rec.ToVer
sameCommit := rec.ToCommit == "" || cur.Commit == rec.ToCommit
confirmed = sameVer && sameCommit
@ -170,10 +202,15 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error
}
// 先备份"当前这个坏的"——万一回滚目标也有问题,至少能回到现场
//
// ★ 落在 dataDir 下,不是二进制目录:二进制目录通常是 /usr/local/bin,
// 只读(ProtectSystem=strict)⇒ MkdirAll 静默失败(旧代码没查 err),
// 现场丢失且日志里只有一条无关的 copy 失败。
ts := NowStamp()
brokenDir := filepath.Join(filepath.Dir(binPath), "homed-broken-"+ts)
os.MkdirAll(brokenDir, 0755)
if err := CopyFile(binPath, filepath.Join(brokenDir, "homed")); err != nil {
brokenDir := BrokenDirPath(dataDir, ts)
if err := os.MkdirAll(brokenDir, 0755); err != nil {
log.Printf("[update] create broken-binary dir %s failed: %v", brokenDir, err)
} else if err := CopyFileAtomic(binPath, filepath.Join(brokenDir, "homed")); err != nil {
// 备份失败不阻断回滚:回滚是主线,留现场是支线
log.Printf("[update] backup of broken binary failed: %v", err)
} else {
@ -181,22 +218,192 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error
}
// 原子替换回旧版
if err := CopyFile(backupBin, binPath); err != nil {
return false, "", "", fmt.Errorf("回滚二进制: %w", err)
if err := CopyFileAtomic(backupBin, binPath); err != nil {
// ★ 不清 marker:拷贝失败时磁盘上可能已是**半新半旧**的文件,
// 保留标记才有"下次启动再试"的机会。
return false, "", "", fmt.Errorf("回滚二进制: %w(marker 保留,下次启动会再试)", err)
}
// ★★★ 回滚的可信性判据:**digest**,不是 `--version`。
//
// ## 为什么不能要求 `--version`(这是 PR #31 引入的兼容性回归)
//
// 旧内核(1.3.x 及以前)**没有 `--version` flag** —— 它是 2026-10-25
// 才加的。而旧内核恰恰是最常见的回滚目标:从 1.3.0 升到 1.4.0、
// 1.4.0 起不来,要回滚的位置就是 1.3.0。
//
// 要求回滚后读版本,结果就是:文件已正确还原成可执行的 1.3.0,
// 但读取严格版本失败 ⇒ 报“回滚失败” ⇒ kernelmod/update 拒绝启动
// ⇒ **从旧版本升级失败后永久回滚不了**,而 marker 保留使其卡在
// 同一个循环里。
//
// ## digest 为什么是更强的判据
//
// 要证明的不是“它能报版本”,而是“恢复的就是升级前那一份”。
// marker 里记了升级前每个文件的 sha256,回滚后逐一校对 ——
// 比 `CopyFile 没报错` 强(字节搬完 ≠ 内容一致),也比 `--version`
// 强(后者对旧版本不可用,对零字节备份可能撞到别的版本)。
rb, verr := verifyRestored(rec, binPath)
if verr != nil {
// 不清 marker:让下次启动再试(也让人有机会介入)
return false, "", "", fmt.Errorf(
"回滚后校验失败: %v;marker 保留(备份可能已损坏,需人工用已知良好版本替换 %s)",
verr, binPath)
}
// 回滚 depgraph.bin(必须与 homed 同批)
if backupDG := filepath.Join(rec.BackupDir, "depgraph.bin"); FileExists(backupDG) {
if err := CopyFile(backupDG, filepath.Join(filepath.Dir(binPath), "depgraph.bin")); err != nil {
return false, "", "", fmt.Errorf("回滚 depgraph.bin: %w", err)
}
//
// ★ 三种情况,缺一不可(旧实现只处理了第一种):
//
// 原来有 + 备份有 → 还原
// 原来有 + 备份无 → 不算成功(两文件不同批)
// 原来无 → **删掉**新包装上的那份,否则旧 homed 的内嵌
// digest 对不上 ⇒ kernelboot 拒绕启动 ⇒ 崩溃循环
if err := restoreDepGraph(rec, binPath); err != nil {
// ★ 不清 marker:homed 回滚了但 depgraph 没跟上 ⇒ 两者不同批。
// 这种状态**不能**当成功:内核启动时会因依赖图不匹配而失败,
// 保留 marker 才能让下次启动再试一遍(且人能看到日志)。
return false, "", "", err
}
// 清标记:已回到旧版,不再有"待确认的新版"
// 只有上面全部通过才清 marker:已确认回到旧版且**可执行**,
// 不存在"待确认的新版"。
if err := ClearPendingUpgrade(dataDir); err != nil {
log.Printf("[update] clear pending after rollback: %v", err)
}
log.Printf("[update] rolled back to %s(备份 %s)", rec.FromVer, rec.BackupDir)
log.Printf("[update] rolled back to %s(备份 %s,已校验)", rb, rec.BackupDir)
return true, rec.FromVer, rec.ToVer, nil
}
// verifyRestored 校验回滚后的状态,返回它现在报的版本(仅作日志用)。
//
// 判据优先级(新 marker 走 digest,旧 marker 降级):
//
// ① marker 里有 Digests[binPath] ⇒ 逐字节 sha256 比对(最可靠)
// ② 没有 digest(旧 marker)⇒ 至少要求能执行且版本 == FromVer
// ③ FromVer 为空 ⇒ 只要求能执行
//
// ★ 降级路径不删除:现场可能还躺着旧 marker(升级进行中、marker 由旧版
// 本写的),而那种 marker 没有 digests。让它显式降级,而不是直接失败。
func verifyRestored(rec *PendingRecord, binPath string) (string, error) {
abs, err := filepath.Abs(binPath)
if err != nil {
abs = binPath
}
want, hasDigest := rec.Digests[abs]
if !hasDigest {
want, hasDigest = rec.Digests[binPath]
}
if hasDigest {
got, serr := sha256File(binPath)
if serr != nil {
return "", fmt.Errorf("计算 %s 的 sha256: %w", binPath, serr)
}
if !strings.EqualFold(got, want) {
return "", fmt.Errorf("digest 不符:期望 %s,实际 %s", ShortSum(want), ShortSum(got))
}
// digest 对上已足够。版本仅用于日志:老内核报不出就报 "unknown"。
if v, verr := readBinVersionStrict(binPath); verr == nil {
return v.Version, nil
}
return rec.FromVer, nil
}
// 旧 marker:没有 digest,降级为“还原内容 == 备份内容”。
//
// ★ 为什么不再用 `--version`(PR #31 的回归):老内核没有这个 flag,
// 而老内核恰恰是最常见的回滚目标 —— 要求它就把“从旧版升级失败”
// 变成永久不能回滚。
//
// 这个降级只能证明“拷过去的就是备份里那份”,不能证明备份本身完好 ——
// 而在旧 marker 上没有更好的证据可用(digest 是本次新增的字段)。
// 新写的人一定有 digest,所以这个分支只在升级进行中、跨版本的
// 遗留 marker 上生效。
backupBin := filepath.Join(rec.BackupDir, filepath.Base(binPath))
wantSum, werr := sha256File(backupBin)
if werr != nil {
return "", fmt.Errorf("备份不可读(%s): %w", backupBin, werr)
}
gotSum, gerr := sha256File(binPath)
if gerr != nil {
return "", fmt.Errorf("读回 %s: %w", binPath, gerr)
}
if !strings.EqualFold(gotSum, wantSum) {
return "", fmt.Errorf("还原内容与备份不一致(期望 %s,实际 %s)",
ShortSum(wantSum), ShortSum(gotSum))
}
// 备份可能与 marker 的 FromVer 不符(备份被别的升级覆盖过)。
// 读得到版本就校,读不到(老内核)就放行 —— 内容一致已经是本分支的上限。
if v, verr := readBinVersionStrict(binPath); verr == nil {
if rec.FromVer != "" && v.Version != rec.FromVer {
return "", fmt.Errorf("版本不符:期望 %s(标记里的 FromVer),实际 %s",
rec.FromVer, v.Version)
}
return v.Version, nil
}
return rec.FromVer, nil
}
// restoreDepGraph 把 depgraph.bin 恢复到升级前的状态。
//
// 这个函数的存在理由是一个真实的崩溃循环:新包带了 depgraph、而原机器
// 没有时,回滚只换 homed 不删 depgraph ⇒ 「旧 homed + 新 depgraph」。
// 生产构建的 homed 会校验编译期内嵌的图 digest,不匹配则**拒绝启动**,
// 而 marker 此时已被清 ⇒ 既起不来也回滚不了。
func restoreDepGraph(rec *PendingRecord, binPath string) error {
dgPath := filepath.Join(filepath.Dir(binPath), "depgraph.bin")
// ★ 先看备份里到底有没有 depgraph,再看 marker 怎么说。
//
// 为什么不能只看 rec.HadDepGraph:这个字段是本次新增的,**存量 marker
// 里都是零值 false**。而一个从旧版本升上来的机器显然是有 depgraph 的
// (打包脚本强制同装)。只看字段会把“该还原”误判成“该删除”。
//
// 反向也成立:字段说 true 但备份里没有,说明备份不完整 —— 那时不能
// 把依图留着不管(会与旧 homed 不批),必须报错。
//
// 所以:**备份里有就是事实**,它优先级高于 marker 字段。
backupDG := filepath.Join(rec.BackupDir, "depgraph.bin")
haveBackup := FileExists(backupDG)
if !haveBackup && !rec.HadDepGraph {
// 升级前没有 ⇒ 现在也不该有。
if err := os.Remove(dgPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("移除升级时新增的 %s: %w(marker 保留)", dgPath, err)
}
log.Printf("[update] removed %s(升级前不存在)", dgPath)
return nil
}
if !haveBackup {
return fmt.Errorf("备份缺 depgraph.bin(%s):homed 已回滚但依图未跟上,marker 保留", backupDG)
}
if err := CopyFileAtomic(backupDG, dgPath); err != nil {
return fmt.Errorf("回滚 depgraph.bin: %w(homed 已回滚但 depgraph 未跟上,marker 保留)", err)
}
// 有 digest 就校:依图错了一样本会拒绕启动。
abs, _ := filepath.Abs(dgPath)
if want, ok := rec.Digests[abs]; ok {
if got, serr := sha256File(dgPath); serr != nil || !strings.EqualFold(got, want) {
return fmt.Errorf("depgraph.bin digest 不符(marker 保留)")
}
}
log.Printf("[update] restored %s", dgPath)
return nil
}
// quarantineMarker 把读不懂的 marker 改名留存(不删)。
//
// 为什么改名而不是删除:那个文件里可能有部分可用信息(比如完整的
// from_version 但 to_ 被截断),是排查"上次升级到底发生了什么"的唯一线索。
// 删了就永久没了。改名能让下一次启动干净地当作"无标记",同时保留现场。
func quarantineMarker(dataDir string) (string, error) {
src := pendingPath(dataDir)
dst := src + ".corrupt-" + NowStamp()
if err := os.Rename(src, dst); err != nil {
return "", err
}
return dst, nil
}

View File

@ -87,6 +87,30 @@ func (s Source) Resolve(workDir string) (root string, rel *ReleaseInfo, err erro
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 {