mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
三条主线,由 e2e 实测驱动。 ## 一、差分 OTA(internal/update/delta.go) 实测:改一行逻辑,88MB 的二进制逐字节只差 80 字节,而 OTA 一直下整包。 用 rsync 族的滚动哈希块匹配(4KB 粒度),不是 bsdiff —— 后者需 17×N 峰值内存(88MB → 1.5GB),而升级恰可能发生在内存紧张时。 真实二进制实测:88,036,728 字节 → 补丁 77,316 字节(0.088%)。 三道闸:应用前验旧文件 sha256(错基线会静默产出垃圾)、应用后验结果 sha256、COPY/LITERAL 越界防护。任何失败**退回整包**,不向上抛 —— 差分是优化,不是可用性前提。 接入:Source.Resolve 优先试 delta;CheckRelease 按 -from-<commit8>.delta 匹配(不猜基线);CLI 新增 -make-delta;打包脚本 build_delta(需 PREVIOUS_HOMED),delta 进 SHA256SUMS。 ## 二、安装形态分派(internal/update/install.go + channel.go) 内核对「能不能自己升级」此前只有一个答案:直接替换二进制。而 deb/rpm 形态下那样做会让 dpkg/rpm 数据库与实际内容不一致(下次 apt upgrade 覆盖回旧版,或 dpkg 拒绝后续升级)。 新增探测:deb/rpm/tar/container/source 五类形态 + 变体(full/server/ client/toolchain)+ 目录写权限。判据全部来自可观测系统事实(dpkg 的 info 目录、容器标记文件),不做路径推断。 三端统一走 PlanUpgrade 分派;形态闸门放在**下载 880MB 之前**。 ## 三、事件总线统一受安全监控(internal/events + security) 用户指正:「eventbus 上所有事件都要接受安全模块的监控」。 此前 EventPolicy 只被 internal/sdk/events.go 调用,而那是四条路径中的 一条。s.Publish / s.Subscribe / s.EventBus() 拿的是裸 *events.Bus, 任何插件都能绕过策略发任意事件(含伪造内核事件)。 现在闸门在 Bus.Publish/Subscribe 本体上(Guard 接口注入,避免 events ↔ security 成环),四条路径全部覆盖。sdk.EventBus() 改返回带身份的 BusHandle。内核身份免授权,但载荷照样过凭据闸门。 白名单按主体收窄:agent_output/stage/terminal_output 只允许承载者 (webui/cli/agentcli)发布 —— 它们是内核向外的输出面,谁都能发就等于 能伪造 agent 说的话。 ## e2e 暴露并修掉的三个真实回归 [bus] subscribe denied: pluginmgr type=update_request [bus] subscribe denied: skillmgr type=skill_detected 前两个让升级请求静默丢失、职责链交接断掉。内部协作事件必须可订阅 (安全边界在发布侧,订阅只是「听得到」)。 ## 顺带的止血修复 - 回滚判据从 `--version` 改为 digest:旧内核(1.3.x 及以前)没有该 flag,而它恰是最常见的回滚目标 —— 原逻辑让「从旧版升级失败」永久 无法回滚。 - CopyFile 补 fsync(替换 homed 本体时掉电会留半截文件)。 - restore 完整还原才清 marker(原先先清再还原,还原失败就失去第二次 自愈机会)。 - 回滚时移除升级中新增的 depgraph(否则「旧 homed + 新 depgraph」 会因 digest 不匹配崩溃循环)。 - homed-upgrade 不再硬编码 systemctl restart(丢 guard 状态、unit 名 漂移);改用内核已有的 restartCh → exit 42 语义。 - homed-upgrade 不再用 CGO_ENABLED=0 编却依赖 go-sqlite3(stub 下 静默失效,该路径从未工作过);改用 internal/config。 - 服务单元补 User=hologramagent + 三条安装路径补建用户(此前发行物里 完全没有落实降权)。 - rpm 的 fpm 调用补 --after-install(此前 rpm 装完根本不建用户)。 ## 判据 新增 30 条:差分 8(往返/压缩比/错基线/截断/篡改/边界)、通道 8、 执行器 6、事件闸门 8、按主体授权 8。全仓 go test ./... 0 失败。
649 lines
22 KiB
Go
649 lines
22 KiB
Go
// 内核升级闭环:探测 / 校验 / 备份 / 原子替换 / 重启 / 回滚。
|
||
// 包级说明见 doc.go(update 子系统)。
|
||
//
|
||
// 抽成独立包而不是留在 cmd/homed-upgrade:内核侧的 kernel_update 工具
|
||
// 与 CLI 必须走**同一套**逻辑。两份实现必然漂移,而这条路径上漂移的
|
||
// 后果是"CLI 能升级、agent 调用却把机器搞坏"。
|
||
//
|
||
// ## 范围:只面向内核
|
||
//
|
||
// 只处理 homed / depgraph.bin / waiter。**不碰插件** —— 插件有独立通道
|
||
// (plugin_update),且与内核的兼容性由"自动禁用不兼容插件"兜底。
|
||
package update
|
||
|
||
import (
|
||
"archive/tar"
|
||
"compress/gzip"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||
)
|
||
|
||
// binVersion 是 homed --version 的输出。
|
||
type BinVersion struct {
|
||
Version string
|
||
Commit string
|
||
BuildTime string
|
||
SDKVersion string
|
||
}
|
||
|
||
// 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
|
||
}
|
||
out, err := exec.Command(bin, "--version").CombinedOutput()
|
||
if err != nil {
|
||
// ★ 回退:老版本内核没有 --version(它是 2026-10-25 才加的)。
|
||
//
|
||
// 这正是 OTA 的**第一次**升级必然撞到的情况:新版工具要读线上版本,
|
||
// 而线上那个版本还不支持被读。此时不能失败退出——否则所有人第一次
|
||
// 升级都要人工介入。改为从运行中进程的环境/文件里找线索,找不到就
|
||
// 诚实地说"未知",让版本比较降级(其余预检照常)。
|
||
if v := InferVersionFromRunning(); v != nil {
|
||
return v, nil
|
||
}
|
||
return nil, fmt.Errorf("执行 --version: %v(老版本内核不支持 --version,且无法从运行进程推断)", 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
|
||
}
|
||
|
||
// compareVersions 给出可读结论。返回文案而不是 bool:
|
||
// "能升但不能降"这类判断需要说明理由,用户才敢按 -apply。
|
||
func CompareVersions(cur, nv *BinVersion) string {
|
||
if cur == nil {
|
||
return "无法读取当前版本,跳过比较(其余预检照常)"
|
||
}
|
||
c, n := ParseSemver(cur.Version), ParseSemver(nv.Version)
|
||
switch {
|
||
case n == nil || c == nil:
|
||
// 版本号形态不标准(如 1.4.0dev):退化成字符串比较并说明
|
||
if cur.Version == nv.Version && cur.Commit == nv.Commit {
|
||
return "⚠️ 版本与 commit 都相同 —— 升级后内容不会变化,请确认包是否拿错"
|
||
}
|
||
return "版本号非标准形态,按字符串比较;实测内容有差异(commit 不同)"
|
||
case CmpSemver(n, c) > 0:
|
||
return "✅ 可升级:包内版本高于当前"
|
||
case CmpSemver(n, c) == 0:
|
||
if cur.Commit != nv.Commit {
|
||
return fmt.Sprintf("⚠️ 同为 %s 但 commit 不同(%s → %s)—— 这是一次**同版本重发**,请确认是否有意为之",
|
||
nv.Version, ShortCommit(cur.Commit), ShortCommit(nv.Commit))
|
||
}
|
||
return "⚠️ 与当前完全相同 —— 无需升级"
|
||
default:
|
||
return "❌ 这是**降级**(包内版本低于当前)。默认不建议;确认要回退请加 -allow-downgrade"
|
||
}
|
||
}
|
||
|
||
// Semver / ParseSemver / CmpSemver 已收敛到 semver.go(子系统内唯一一份)。
|
||
// 合并前这里有一份私有实现,与 plugin 侧另写的一份行为不一致 —— 见 semver.go 头注释。
|
||
|
||
// pluginCompat 是一个与新内核 SDK 主版本不匹配的插件。
|
||
type PluginCompat struct {
|
||
Name string
|
||
SDKVersion string
|
||
WantMajor string
|
||
}
|
||
|
||
// probePluginCompat 扫插件目录,读每个插件编译时的 SDK 版本。
|
||
//
|
||
// 数据来源:plugin.json 里**没有** sdk_version 字段,所以读不到就诚实地说
|
||
// "无法预判",而不是瞎猜。能读到的(有 meta 文件的)才比较。
|
||
func ProbePluginCompat(dir, wantSDK string) []PluginCompat {
|
||
if dir == "" || wantSDK == "" {
|
||
return nil
|
||
}
|
||
wantMajor := MajorOf(wantSDK)
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var out []PluginCompat
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
pj := filepath.Join(dir, e.Name(), "plugin.json")
|
||
b, err := os.ReadFile(pj)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
var m struct {
|
||
SDKVersion string `json:"sdk_version"`
|
||
}
|
||
json.Unmarshal(b, &m)
|
||
if m.SDKVersion == "" {
|
||
continue // plugin.json 不含该字段:无法预判,不虚报
|
||
}
|
||
if MajorOf(m.SDKVersion) != wantMajor {
|
||
out = append(out, PluginCompat{Name: e.Name(), SDKVersion: m.SDKVersion, WantMajor: wantMajor})
|
||
}
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||
return out
|
||
}
|
||
|
||
func MajorOf(v string) string {
|
||
if i := strings.IndexByte(v, '.'); i > 0 {
|
||
return v[:i]
|
||
}
|
||
return v
|
||
}
|
||
|
||
func ShortCommit(c string) string {
|
||
if c == "" || c == "unknown" {
|
||
return "unknown"
|
||
}
|
||
if len(c) > 8 {
|
||
return c[:8]
|
||
}
|
||
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)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer f.Close()
|
||
gz, err := gzip.NewReader(f)
|
||
if err != nil {
|
||
return "", fmt.Errorf("非 gzip: %w", err)
|
||
}
|
||
defer gz.Close()
|
||
tr := tar.NewReader(gz)
|
||
for {
|
||
h, err := tr.Next()
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
// 防路径穿越(包是被信任的,但这条成本几乎为零)
|
||
clean := filepath.Clean(h.Name)
|
||
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
||
return "", fmt.Errorf("包内有非法路径: %s", h.Name)
|
||
}
|
||
p := filepath.Join(dst, clean)
|
||
switch h.Typeflag {
|
||
case tar.TypeDir:
|
||
os.MkdirAll(p, 0755)
|
||
case tar.TypeReg:
|
||
os.MkdirAll(filepath.Dir(p), 0755)
|
||
out, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if _, err := io.Copy(out, tr); err != nil {
|
||
out.Close()
|
||
return "", err
|
||
}
|
||
out.Close()
|
||
}
|
||
}
|
||
// 找含 bin/homed 的那层
|
||
if FindFile(dst, "homed") == "" {
|
||
return "", fmt.Errorf("包内无 homed")
|
||
}
|
||
return dst, nil
|
||
}
|
||
|
||
// findFile 在 root 下递归找文件名匹配的第一个。
|
||
func FindFile(root, name string) string {
|
||
var found string
|
||
filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||
if err != nil || info.IsDir() || found != "" {
|
||
return nil
|
||
}
|
||
if info.Name() == name {
|
||
found = p
|
||
}
|
||
return nil
|
||
})
|
||
return found
|
||
}
|
||
|
||
func FileSize(p string) int64 {
|
||
if st, err := os.Stat(p); err == nil {
|
||
return st.Size()
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// Apply 执行真实升级:预检 → 备份 → 写 pending → 原子替换。
|
||
//
|
||
// ## ★ 本函数**不重启**内核
|
||
//
|
||
// 重启必须由**调用方**通过内核控制通道发起,理由见
|
||
// cmd/homed/kernel_control.go 的说明:worker 是被守护方,只有 guard
|
||
// 知道这次退出该不该重拉(exit 42 → 重拉且不计失败轮次)。
|
||
//
|
||
// 本包若自己调 `systemctl restart`,会带来三个问题:
|
||
//
|
||
// · 杀掉整个 cgroup(含 guard)—— 把监督者一起杀掉
|
||
// · 丢失 exit 42 语义:guard 的 failures/failbackRound 状态全丢
|
||
// · 发命令的进程正是被重启杀掉的进程 ⇒ 返回值不可靠,
|
||
// 一次成功的升级会被误判成失败而触发回滚
|
||
//
|
||
// 所以这里只承诺到"磁盘上的产物已就绪,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 {
|
||
return nil, fmt.Errorf("预检失败,新二进制不可执行: %w", err)
|
||
}
|
||
publishProgress(bus, "kernel", "", "verify", "新内核可执行,版本 "+nv.Version)
|
||
fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version)
|
||
|
||
// ── 备份:只备本次会动的文件(不整目录拷贝,那是个几百 MB)──
|
||
//
|
||
// ★ 备份必须落在 dataDir 下,不能是 /root:服务以 ProtectSystem=strict
|
||
// + 非 root 运行,对 /root 没有写权限 ⇒ MkdirAll 必 EACCES。
|
||
ts := NowStamp()
|
||
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}
|
||
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 {
|
||
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)
|
||
}
|
||
}
|
||
publishProgress(bus, "kernel", "", "backup", fmt.Sprintf("已备份 %d 个文件", len(targets)))
|
||
fmt.Printf("已备份 %d 个文件 → %s\n", len(targets), backup)
|
||
|
||
// ── 写待确认升级标记(在替换**之前**)──
|
||
//
|
||
// 顺序很关键:先写标记再替换。若反过来(替换后才写),“替换成功但
|
||
// 没来得及写标记就崩了”会丢标记 ⇒ 下次启动不知道该回滚。
|
||
curVer := &BinVersion{}
|
||
if v, verr := ReadBinVersion(binPath); verr == nil {
|
||
curVer = v
|
||
}
|
||
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)
|
||
|
||
var failed []string
|
||
for _, t := range targets {
|
||
src := filepath.Join(backup, filepath.Base(t))
|
||
// ★ 用 digest 判“已还原”:比“CopyFile 没报错”强。
|
||
// CopyFileAtomic 成功只说明字节搬完了,不说明内容与当时一致
|
||
// (备份本身可能已损坏)。
|
||
if err := CopyFileAtomic(src, t); err != nil {
|
||
failed = append(failed, fmt.Sprintf("%s: %v", t, err))
|
||
continue
|
||
}
|
||
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)
|
||
}
|
||
|
||
// ── 替换(depgraph 必须与 homed 同批,顺序无关但都要成)──
|
||
if newDG != "" {
|
||
touchedDepGraph = true
|
||
if err := CopyFileAtomic(newDG, dgPath); err != nil {
|
||
return restore(fmt.Errorf("替换 depgraph.bin: %w", err))
|
||
}
|
||
fmt.Println("已替换 depgraph.bin")
|
||
}
|
||
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,
|
||
BackupDir: backup,
|
||
}, nil
|
||
}
|
||
|
||
// Outcome 是一次替换的结果(**不含重启**,重启由调用方走内核控制通道)。
|
||
type Outcome struct {
|
||
From *BinVersion // 升级前版本(回滚目标)
|
||
To *BinVersion // 已就位的新版本
|
||
BackupDir string // 备份目录(确认稳定后可删)
|
||
}
|
||
|
||
// CopyFile 是**向后兼容的别名**,内部走 CopyFileAtomic(含 fsync)。
|
||
//
|
||
// ★ 保留它而不是删:旧调用点(含测试)仍在用这个名字,而旧实现在
|
||
//
|
||
// rename 前**没有 fsync** —— 掉电后会留下半截文件。统一到原子实现,
|
||
// 调用方无需改一行。
|
||
func CopyFile(src, dst string) error { return CopyFileAtomic(src, dst) }
|
||
|
||
func FileExists(p string) bool {
|
||
_, err := os.Stat(p)
|
||
return err == nil
|
||
}
|
||
|
||
func NowStamp() string {
|
||
return time.Now().Format("20060102-150405")
|
||
}
|
||
|
||
// inferVersionFromRunning 在老内核不支持 --version 时,尽力推断当前版本。
|
||
//
|
||
// 手段按可靠性排序,任一成功即返回:
|
||
// 1. 读 /proc/<pid>/exe 指向的文件,用 strings 找 "version=" 形态(不保证有)
|
||
// 2. 读 WebUI 的 /api/v1/agents(那里返回 meta.Version)—— 需服务在跑
|
||
//
|
||
// 都失败则返回 nil,调用方把比较降级为"未知",**不阻断**升级。
|
||
func InferVersionFromRunning() *BinVersion {
|
||
// 手段 1:从二进制里找注入的版本串。
|
||
//
|
||
// ldflags 注入的字符串在 rodata 里是裸的,形如 "1.4.0dev";
|
||
// 配合 "commit=" 一起找才有意义,所以这里只认成对出现的情况。
|
||
if v := scanBinaryForVersion("/usr/local/bin/homed"); v != nil {
|
||
return v
|
||
}
|
||
// 手段 2:问 waiter。
|
||
//
|
||
// ★ 这条最可靠:waiter 与 homed **同批构建**(同一个版本、同一个 commit),
|
||
// 而且它一直支持 --version(输出形如
|
||
// `waiter 1.4.0 (commit 98850e1, built 2026-10-17T09:20:25Z)`)。
|
||
// 老内核没有 --version,但同批的 waiter 有 —— 用它反推。
|
||
if v := versionFromWaiter("/usr/local/bin/waiter"); v != nil {
|
||
return v
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// versionFromWaiter 解析 waiter 的版本行反推内核版本。
|
||
//
|
||
// 只认 "waiter X.Y.Z (commit ABC, built ...)" 这一种形态;
|
||
// 格式变了就返回 nil(宁可说"未知",不可瞎猜)。
|
||
func versionFromWaiter(path string) *BinVersion {
|
||
if _, err := os.Stat(path); err != nil {
|
||
return nil
|
||
}
|
||
out, err := exec.Command(path, "--version").CombinedOutput()
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
line := strings.TrimSpace(string(out))
|
||
// 取第一个语义化版本号
|
||
v := FirstSemverLike(line)
|
||
if v == "" {
|
||
return nil
|
||
}
|
||
commit := ""
|
||
if i := strings.Index(line, "commit "); i >= 0 {
|
||
rest := line[i+len("commit "):]
|
||
if j := strings.IndexAny(rest, ",)"); j > 0 {
|
||
commit = strings.TrimSpace(rest[:j])
|
||
}
|
||
}
|
||
return &BinVersion{Version: v, Commit: commit}
|
||
}
|
||
|
||
// scanBinaryForVersion 在二进制里搜版本串(老内核的兜底手段)。
|
||
func scanBinaryForVersion(path string) *BinVersion {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer f.Close()
|
||
// 只读前 200MB:版本串在 rodata,通常在文件前 1/3
|
||
buf := make([]byte, 200<<20)
|
||
n, _ := io.ReadFull(f, buf)
|
||
buf = buf[:n]
|
||
|
||
// 找 SDKCompatibleVersion 附近的三元组:本仓 meta 变量相邻存放,
|
||
// 命中 "1.3.0" 且附近有语义化版本形态时,取最近的一个当内核版本。
|
||
// ★ 这是**尽力而为**:可能撞到别的版本号,所以只作为"未知"的替代,
|
||
// 且返回的 commit 恒为 unknown(无法可靠恢复)。
|
||
idx := strings.Index(string(buf), "SDKCompatibleVersion")
|
||
if idx < 0 {
|
||
return nil
|
||
}
|
||
// 在附近 4KB 里找 X.Y.Z 形态
|
||
window := string(buf[maxInt(0, idx-4096):minInt(len(buf), idx+4096)])
|
||
if v := FirstSemverLike(window); v != "" {
|
||
return &BinVersion{Version: v, Commit: "unknown"}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func FirstSemverLike(s string) string {
|
||
for i := 0; i+5 <= len(s); i++ {
|
||
if s[i] < '0' || s[i] > '9' {
|
||
continue
|
||
}
|
||
j := i
|
||
dots := 0
|
||
for j < len(s) && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.') {
|
||
if s[j] == '.' {
|
||
dots++
|
||
}
|
||
j++
|
||
}
|
||
if dots == 2 && j-i >= 5 {
|
||
return s[i:j]
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func maxInt(a, b int) int {
|
||
if a > b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func minInt(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|