mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
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:
386
internal/update/apply_regression_test.go
Normal file
386
internal/update/apply_regression_test.go
Normal 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
195
internal/update/channel.go
Normal 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")
|
||||
}
|
||||
197
internal/update/channel_test.go
Normal file
197
internal/update/channel_test.go
Normal 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
370
internal/update/delta.go
Normal 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
|
||||
128
internal/update/delta_ota.go
Normal file
128
internal/update/delta_ota.go
Normal 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
|
||||
}
|
||||
188
internal/update/delta_test.go
Normal file
188
internal/update/delta_test.go
Normal 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
180
internal/update/executor.go
Normal 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
|
||||
}
|
||||
160
internal/update/executor_test.go
Normal file
160
internal/update/executor_test.go
Normal 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
145
internal/update/fsutil.go
Normal 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
141
internal/update/gc.go
Normal 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
548
internal/update/install.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user