mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 23:07:30 +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 失败。
549 lines
20 KiB
Go
549 lines
20 KiB
Go
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
|
||
}
|