Files
HomeAgent/internal/update/kernel.go
JianFeeeee f9ee58a914 merge: pr31 — update 回滚安全(原子写 + 回读校验),解 19 处冲突
pr31 与 main 是**双向分叉**,不是单向落后。`ba281a8` 已把它的多数工作
在 main 上重新实现(dataDir 守卫、gc.go、dataDir 解析),但两边各自又
往前走了,所以 6 个文件 19 处冲突要逐条判。

## 合进来的(main 缺)

- **标记原子写**:main 是 `os.WriteFile` 直接覆写,掉电留半截标记;
  改为 `写 .tmp → fsync → close → rename → fsync 目录`
- **H4**:`publishResult` 里 `if errMsg != "" { _ = errMsg }` 把失败原因
  丢了,订阅方只看到 ok=false。现在 error 字段恒定存在
- **回读校验补最低可用性**:main 对无 digest 的旧 marker 只比"内容与备份
  一致",而**零字节备份内容也一致** ⇒ 判成回滚成功、不清 marker 的时机
  也错过了。补"非空 + 带可执行位",刻意**不**要求 `--version`
  (老内核没这个 flag,那正是 PR #31 的回归)
- `kernel_apply_test.go`(836 行,含 B2c `TestMarkPendingUpgrade_PreservesOldMarkerOnFailure`)

## 保留 main 的(pr31 在这几处是旧版或更差)

- **回滚校验用 digest**(`verifyRestored`)而不是 `--version`:
  pr31 要求回滚后能读版本,而 1.3.x 及以前没有 `--version`,
  而它们恰恰是最常见的回滚目标 ⇒ 从旧版升级失败后永久回滚不了
- **`restoreDepGraph` 三态**:原来有+备份有→还原、原来有+备份无→不算成功、
  原来无→删掉新装的。pr31 只处理第一种,会导致「旧 homed + 新 depgraph」
  的崩溃循环
- **marker 在还原完整后才清**:pr31 在还原前就清,还原失败即失去第二次自愈
- **CLI 不重启服务**:pr31 仍带 `restartUnit`,会杀掉整个 cgroup 连带 guard
- **`resolveDataDir`**:pr31 的 `upgradeDataDir` 是它的子集,多扫几个候选前缀

## 冲突处理中的三处重复声明

两边各加了同名函数,自动合并把它们都留下了:

- `readBinVersionStrict` —— 删 pr31 的(main 在 77 行有)
- `kernelDataDirMu/kernelDataDir/SetKernelDataDir/KernelDataDir` —— 删 kernel.go
  里那份(main 在 install.go)
- `newPkgRoot` —— pr31 的重命名 `newPkgRootFromBin`,两套测试各自可用
- rollback.go 里 `verifyRestored` 与 `readBinVersionStrict` 两段校验并存 —— 删后者

顺带修掉删除时误伤的 `ShortSum` 注释,并删掉 pr31 引入但无人调用的
`DataDirEnv`(main 明确不再拿 env 当 dataDir 来源)。

## 验证

    go build ./...                        ✓
    go vet ./...                          ✓
    go test ./... -count=1                62 ok / 0 fail
    go test -race ./internal/update/ ...  ✓
    gofmt -l                              干净
2026-10-31 15:56:10 +08:00

646 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 内核升级闭环:探测 / 校验 / 备份 / 原子替换 / 重启 / 回滚。
// 包级说明见 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) {
v, err := readBinVersionStrict(bin)
if err == nil {
return v, nil
}
// ★ 回退:老版本内核没有 --version(它是 2026-10-25 才加的)。
//
// 这正是 OTA 的**第一次**升级必然撞到的情况:新版工具要读线上版本,
// 而线上那个版本还不支持被读。此时不能失败退出——否则所有人第一次
// 升级都要人工介入。改为从运行中进程的环境/文件里找线索,找不到就
// 诚实地说"未知",让版本比较降级(其余预检照常)。
//
// ★★ 注意:这个回退使 ReadBinVersion **不能**用做回滚校验。
// 推断出来的版本可能来自**另一个文件**(或运行中的老进程),拿它当
// "刚拷过去的文件能执行"的证词,会让一个零字节约的坏备份"校验通过"。
// 回滚路径必须用 readBinVersionStrict(见 rollback.go)。
if iv := InferVersionFromRunning(); iv != nil {
return iv, nil
}
return nil, fmt.Errorf("执行 --version: %v(老版本内核不支持 --version,且无法从运行进程推断)", err)
}
// readBinVersionStrict 只认**这个文件自己**能报出版本,任何回退都不要。
//
// 用于**回滚校验**:我们要证明的是"刚替换上去的那个文件是可执行的且
// 版本对",而任何形式的推断(读别的路径、问运行中的进程)都会把一个
// 死文件"验证"成活的。
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 {
// ★ 这里**不**做任何回退(与 ReadBinVersion 的关键区别):
// 执行失败就说明这个文件不可用,回退会把死文件验成活的。
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
}
// 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
}
// 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 + ReadWritePaths=/var/lib/hologram 运行,
// 非 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 事件。
//
// 此前只有失败路径(restore)发 result,成功路径什么都不发 ⇒ 订阅方
// 只能靠"进度停在最后一步且没看到 failed"来猜成功,猜错的方向是
// "以为还在升级"。先发事件再返回:返回后调用方可能立刻重启,进程消失。
publishResult(bus, "kernel", "", true, curVer.Version, nv.Version, false, "")
// 备份必须 GC:否则每次升级都在 dataDir 下留几十 MB,旧注释写
// "确认稳定后可删",但全仓从来没有一行删除逻辑。
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,调用方把比较降级为"未知",**不阻断**升级。
// 推断逻辑读的两个路径。
//
// ★ 提为变量(而不是写死在函数体里)的唯一目的是**可测**:
// 以前它们直接内联在 InferVersionFromRunning 里,导致“确认分支不得使用
// 推断值”这条判据只能在**装了 homed/waiter 且 /usr/local/bin 可写**的机器上
// 验证。那是典型的“CI 假绿、生产真红”形状:跑不到就 Skip,跳着跳着就没人看。
//
// 生产行为完全不变(默认值就是原来那两个绝对路径,没人会改它们);
// 只有测试会临时指向自己造的假文件,从而能**稳定复现**“借到别人版本”的场景。
var (
inferHomedPath = "/usr/local/bin/homed"
inferWaiterPath = "/usr/local/bin/waiter"
)
func InferVersionFromRunning() *BinVersion {
// 手段 1:从二进制里找注入的版本串。
//
// ldflags 注入的字符串在 rodata 里是裸的,形如 "1.4.0dev";
// 配合 "commit=" 一起找才有意义,所以这里只认成对出现的情况。
if v := scanBinaryForVersion(inferHomedPath); 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(inferWaiterPath); 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
}