mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
feat(update): update 注册为内核标准模块(第 0 层)+ 启动失败自动回滚
## 为什么必须是第 0 层(用户口径) update 肩负「启动失败回滚上一次升级」的职责,该职责必须**早于所有模块**。 若排在后面:升级后新内核在 memory/llm 阶段崩了,而回滚还没跑 ⇒ 机器进 崩溃循环,只能靠 guard 的 failback(受限启动)兜底。failback 是最后手段, 不该承担"换错二进制"这种**已知可回滚**的故障。 ⇒ Requires() 为空 ⇒ 依赖图天然把它放 Layers[0]。 ## 两段式升级确认(rollback.go) 升级成功 ──→ 写 pending 标记("这个版本尚未被确认能启动") ↓ 重启 → 内核启动 → update 模块(第 0 层)读标记 ↓ ┌────────────┴────────────┐ 标记还在 标记已不在 (上次升级没能活到清除) (上次升级已确认成功) ↓ ↓ 回滚到备份版本 正常继续 + 清标记 清除时机:模块跑完自检后立刻清。只依赖"进程活着且执行到第 0 层", 不依赖其他模块 ⇒ 不会误判。 **为什么不用 TTL / 重启次数**:那是 guard 已有的 failback,但判据是 "崩了几次",不是"这次升级该不该撤销"。用重启次数会在"新版本偶发崩溃"时 误回滚 —— 而那种情况该修的是崩溃,不是回退版本。 ## 分层(与仓库其他模块同一惯例) internal/update/ 业务实现(三端共用:CLI/WebAPI/agent 工具) internal/kernelmod/update/ 适配层(读 ks → 调业务包 → 写回 ks) 参照 supervisor 模块:实现在 internal/supervisor,模块里只做装配。 分开的理由:internal/update 被三端调用,它们都没有 KernelState。 ## 标记写入时机(一个真实的顺序坑) MarkPendingUpgrade 在**替换二进制之前**调用。反过来(替换后才写)会在 "替换成功但没来得及写标记就崩了"时丢标记 ⇒ 下次启动不知道该回滚。 且写标记失败 ⇒ **放弃本次升级**(未替换任何文件):没有标记就没有回滚 能力,而不带回滚的升级等于赌运气。 ## 回滚失败必须响亮 备份不存在时返回 error,调用方(模块 Init)必须中止启动 —— 继续装配等于 用一个已知起不来的版本。错误文案带备份路径与标记路径(删标记即可放弃回滚), 人能照着修。 ## 判据 internal/update/rollback_test.go 10 条,2 条经变异验证: · TestRollback_RestoresBackup —— 二进制+depgraph 同批回滚、标记清除、 坏版本留档 homed-broken-*、版本轨迹记录 变异:回滚后不清标记 ⇒ 红 · TestRollback_MissingBackupIsLoud —— 备份缺失时报错且可执行 变异:静默返回 nil ⇒ 红 · TestPendingMarker_AbsentIsNotError —— 无标记返回 (nil,nil) 而非 error (否则每次启动都走"回滚失败"分支 ⇒ 内核永远起不来) · TestPendingMarker_CorruptIsActionable —— 标记损坏时提示可删 · TestStateSummary_* —— 回滚信息进 healthcheck;nil 安全 ## 顺带修正的既有判据 · runtime_dep_test.go:L0 设施集合从 2 个变 3 个(新增 kernel.version), 改为断言**集合**而非顺序(注册表本就是乱序的) · facilities.go:新增 FacVersion,并登记为 internalOnlyFacilities (它是启动时序事实,不是插件契约 —— facilities_sync_test 的守卫正确拦住了 "把内部标记泄漏成对外契约") 三个 DoUpgrade 调用点补 dataDir 参数(标记落点必须与内核 --data 一致)。
This commit is contained in:
@ -33,11 +33,27 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/update"
|
||||
)
|
||||
|
||||
// fail 打印错误并以非零退出(CLI 专属;库内部一律返回 error)。
|
||||
// upgradeDataDir 推导数据目录(升级确认标记的落点)。
|
||||
//
|
||||
// 与生产一致:内核按 `--data <dir>` 运行,标记必须落在同一个 dataDir 下,
|
||||
// 否则 update 模块读不到它。推导规则:
|
||||
// ① 环境变量 HOMEAGENT_DATA_DIR(部署方显式指定)
|
||||
// ② 二进制同级的 ../newqqagent(/usr/local/bin/homed → /home/newqqagent)
|
||||
// —— 太脆弱,改为最常见的部署形态:/usr/local/bin/homed → /home/newqqagent
|
||||
// ③ 退回 /home/newqqagent(本仓默认部署路径)
|
||||
func upgradeDataDir(binPath string) string {
|
||||
if d := strings.TrimSpace(os.Getenv("HOMEAGENT_DATA_DIR")); d != "" {
|
||||
return d
|
||||
}
|
||||
return "/home/newqqagent"
|
||||
}
|
||||
|
||||
func fail(format string, a ...interface{}) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||
os.Exit(1)
|
||||
@ -118,7 +134,7 @@ func main() {
|
||||
}
|
||||
|
||||
// ── 6. 真正升级(备份 → 替换 → 重启 → 后置验证 → 失败回滚)──
|
||||
if err := update.DoUpgrade(root, *binPath, newDG, *pluginsDir); err != nil {
|
||||
if err := update.DoUpgrade(root, *binPath, newDG, *pluginsDir, upgradeDataDir(*binPath)); err != nil {
|
||||
fail("升级失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,4 +40,5 @@ import (
|
||||
_ "github.com/JianFeeeee/HologramAgent/internal/kernelmod/supervisor"
|
||||
_ "github.com/JianFeeeee/HologramAgent/internal/kernelmod/textmem"
|
||||
_ "github.com/JianFeeeee/HologramAgent/internal/kernelmod/tracker"
|
||||
_ "github.com/JianFeeeee/HologramAgent/internal/kernelmod/update"
|
||||
)
|
||||
|
||||
@ -39,6 +39,9 @@ import (
|
||||
// knowledge-migrated)。反向也要防:有人误把内部标记放进 SDK 当对外契约。
|
||||
var internalOnlyFacilities = map[string]bool{
|
||||
"knowledge-migrated": true,
|
||||
// kernel.version 同理:它是 update 模块的启动时序事实("版本自检与
|
||||
// 升级回滚判定已完成"),消费者是内核模块,不是插件契约。
|
||||
"kernel.version": true,
|
||||
}
|
||||
|
||||
// extractFacilities 从 Go 源码里提取 `FacilityXxx = "值"` 形式的常量。
|
||||
|
||||
@ -175,9 +175,24 @@ func TestRunRejectsLayerOrderViolation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 违规发生在装配之前:只跑完了 L0(config / kernel-api)。
|
||||
if strings.Join(dnr.Produced, ",") != kernelmod.FacConfig+","+kernelmod.FacKernelIO {
|
||||
t.Errorf("★ 守卫失败时\"已产出事实\"应为 L0 的两个设施,实际 %v", dnr.Produced)
|
||||
// ③ 违规发生在装配之前:只跑完了 L0。
|
||||
//
|
||||
// ★ L0 现在是三个:config / kernel-api / update。update 模块 Requires
|
||||
// 为空(它肩负"启动失败回滚上一次升级",必须早于所有模块),所以
|
||||
// 天然落在 L0。这里断言**集合**而非顺序 —— 注册表本就是乱序的。
|
||||
got := dnr.Produced
|
||||
wantL0 := map[string]bool{
|
||||
kernelmod.FacConfig: true,
|
||||
kernelmod.FacKernelIO: true,
|
||||
kernelmod.FacVersion: true,
|
||||
}
|
||||
if len(got) != len(wantL0) {
|
||||
t.Errorf("★ 守卫失败时 L0 应产出 %d 个设施,实际 %v", len(wantL0), dnr.Produced)
|
||||
}
|
||||
for _, f := range got {
|
||||
if !wantL0[f] {
|
||||
t.Errorf("★ L0 出现了意外的设施 %q(实际产出 %v)", f, dnr.Produced)
|
||||
}
|
||||
}
|
||||
if ks.LuaVM != nil {
|
||||
t.Error("★ 守卫失败后 ks.LuaVM 非 nil —— 说明 lua 模块被跑过了,与\"装配前校验\"矛盾")
|
||||
|
||||
@ -43,4 +43,14 @@ const (
|
||||
|
||||
// FacMigrated 标记"知识库目录迁移已完成"(用于让 store 排在迁移之后)。
|
||||
FacMigrated = "knowledge-migrated"
|
||||
|
||||
// FacVersion 标记"内核版本自检与升级回滚判定已完成"(update 模块提供)。
|
||||
//
|
||||
// 语义:读到它时,ks.Update 里的版本已经是**回滚后的最终版本**。
|
||||
// 需要按版本行事的模块(healthcheck 上报版本、pluginhost 判插件兼容性)
|
||||
// 应 Requires 它,否则可能读到升级前那个已被回滚的版本。
|
||||
//
|
||||
// 为什么在内部标记段:它是内核自用的启动时序事实,不是插件可选的
|
||||
// 外部契约(插件声明它只会立刻满足,没有意义)。
|
||||
FacVersion = "kernel.version"
|
||||
)
|
||||
|
||||
98
internal/kernelmod/update/mod.go
Normal file
98
internal/kernelmod/update/mod.go
Normal file
@ -0,0 +1,98 @@
|
||||
// Package update 是 update 模块的适配层:把 internal/update 的能力接进内核模块体系。
|
||||
//
|
||||
// ## 为什么分两层(与仓库其他模块同一惯例)
|
||||
//
|
||||
// internal/update/ 业务实现(版本比较、解包、替换、回滚、Release 查询)
|
||||
// internal/kernelmod/update/ 适配层(读 ks → 调业务包 → 写回 ks)
|
||||
//
|
||||
// 参照 supervisor 模块:实现在 internal/supervisor,这里只做装配。
|
||||
// 分开的理由:internal/update 要被 CLI / WebAPI / agent 工具三端调用,
|
||||
// 它们都没有 KernelState;而模块体系只有内核启动一条路径。
|
||||
//
|
||||
// ## 它为什么必须在第 0 层
|
||||
//
|
||||
// 肩负「启动失败回滚上一次升级」的职责(详见 internal/update/rollback.go)。
|
||||
// 该职责必须早于所有其他模块:若排在后面,升级后新内核在 memory/llm 阶段
|
||||
// 崩了,而回滚还没跑 ⇒ 机器进崩溃循环,只能靠 guard 的 failback 兜底。
|
||||
// failback 是最后手段,不该承担"换错二进制"这种已知可回滚的故障。
|
||||
package update
|
||||
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
|
||||
upd "github.com/JianFeeeee/HologramAgent/internal/update"
|
||||
)
|
||||
|
||||
// FacilityVersion 是本模块提供的设施名。
|
||||
//
|
||||
// 语义:启动时的版本自检与回滚判定**已完成**。需要按版本行事的模块
|
||||
// (healthcheck 上报版本、pluginhost 判插件兼容性)应 Requires 它,
|
||||
// 以确保读到的是回滚后的最终版本,而不是升级前的那个。
|
||||
const FacilityVersion = "kernel.version"
|
||||
|
||||
// Module 实现 kernelboot.Module。
|
||||
type Module struct{}
|
||||
|
||||
func (Module) Name() string { return "update" }
|
||||
func (Module) Requires() []string { return nil } // 第 0 层:不依赖任何人
|
||||
func (Module) Provides() []string { return []string{FacilityVersion} }
|
||||
|
||||
// Init 执行版本自检,必要时回滚上一次升级。
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
st := &upd.State{}
|
||||
ks.Update = st
|
||||
|
||||
bin := upd.KernelBinaryPath()
|
||||
st.BinaryPath = bin
|
||||
|
||||
// ① 自报版本(升级是否真换成功,只有二进制自己能答)
|
||||
v, err := upd.ReadBinVersion(bin)
|
||||
if err != nil {
|
||||
// 读不出来不等于致命:可能是极老版本(无 --version)。
|
||||
// 没有证据表明升级失败 ⇒ 不回滚,记录后继续。
|
||||
log.Printf("[update] kernel version probe failed: %v", err)
|
||||
st.ProbeErr = err
|
||||
} else {
|
||||
st.Version, st.Commit, st.SDKVersion = v.Version, v.Commit, v.SDKVersion
|
||||
log.Printf("[update] kernel %s (commit %s, sdk %s)", v.Version, upd.ShortCommit(v.Commit), v.SDKVersion)
|
||||
}
|
||||
|
||||
// ② 上一次升级的回滚判定
|
||||
rolled, from, to, rerr := upd.RollbackIfUnconfirmed(ks.DataDir, bin)
|
||||
if rerr != nil {
|
||||
// 回滚失败必须响亮:继续装配等于用一个已知起不来的版本
|
||||
return rerr
|
||||
}
|
||||
if rolled {
|
||||
log.Printf("[update] ROLLED BACK kernel %s → %s(上一次升级未能确认启动)", to, from)
|
||||
st.RolledBack = true
|
||||
st.RolledFrom, st.RolledTo = from, to
|
||||
// 回滚后重读版本:后续模块要看到**回滚后**的版本
|
||||
if v2, err2 := upd.ReadBinVersion(bin); err2 == nil {
|
||||
st.Version, st.Commit, st.SDKVersion = v2.Version, v2.Commit, v2.SDKVersion
|
||||
}
|
||||
} else {
|
||||
// ③ 版本已确认能启动 ⇒ 清除待确认标记
|
||||
//
|
||||
// 只依赖"进程活着且执行到第 0 层",不依赖其他模块 ⇒ 不会误判。
|
||||
if err := upd.ClearPendingUpgrade(ks.DataDir); err != nil {
|
||||
// 清不掉不阻断启动:后果只是下次启动再判一次(幂等)
|
||||
log.Printf("[update] clear pending marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 依赖图路径(真正的 digest 校验在 kernelboot 装载期,那里失败会拒绝启动)
|
||||
if dg := filepath.Join(filepath.Dir(bin), "depgraph.bin"); upd.FileExists(dg) {
|
||||
st.DepGraphPath = dg
|
||||
}
|
||||
|
||||
log.Printf("[update] subsystem ready (version=%s rolled_back=%v)", st.Version, st.RolledBack)
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() { kernelmod.Register(Module{}) }
|
||||
|
||||
var _ kernelboot.Module = Module{}
|
||||
@ -91,7 +91,7 @@ func (p *Plugin) kernelUpdateApply(pkgPath string) (interface{}, error) {
|
||||
_ = kc.RequestKernelRestart("kernel_update apply")
|
||||
}
|
||||
go func() {
|
||||
if err := update.DoUpgrade(root, kernelBinPath(p), newDG, p.pluginDir); err != nil {
|
||||
if err := update.DoUpgrade(root, kernelBinPath(p), newDG, p.pluginDir, kernelDataDir(p)); err != nil {
|
||||
// DoUpgrade 内部失败已自行回滚;这里只留一条诊断日志
|
||||
fmt.Fprintf(os.Stderr, "[pluginmgr] kernel_update upgrade failed (rolled back): %v\n", err)
|
||||
}
|
||||
@ -102,6 +102,17 @@ func (p *Plugin) kernelUpdateApply(pkgPath string) (interface{}, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// kernelDataDir 推导数据目录(升级确认标记的落点)。
|
||||
//
|
||||
// 从 pluginDir 反推:<dataDir>/plugins ⇒ 上一级即 dataDir。
|
||||
// 与内核 --data 的值一致,update 模块(第 0 层)才能读到同一个标记。
|
||||
func kernelDataDir(p *Plugin) string {
|
||||
if p.pluginDir != "" {
|
||||
return filepath.Dir(filepath.Dir(p.pluginDir))
|
||||
}
|
||||
return "/home/newqqagent"
|
||||
}
|
||||
|
||||
// currentKernelVersion 读本机 homed 自报版本(尽力而为)。
|
||||
func currentKernelVersion(p *Plugin) *update.BinVersion {
|
||||
v, err := update.ReadBinVersion(kernelBinPath(p))
|
||||
|
||||
@ -107,5 +107,5 @@ func runKernelUpgrade(pkgPath string) {
|
||||
if newBin == "" {
|
||||
return
|
||||
}
|
||||
_ = update.DoUpgrade(root, "/usr/local/bin/homed", newDG, "/home/newqqagent/plugins")
|
||||
_ = update.DoUpgrade(root, "/usr/local/bin/homed", newDG, "/home/newqqagent/plugins", "/home/newqqagent")
|
||||
}
|
||||
|
||||
@ -34,7 +34,9 @@
|
||||
// ## 与既有升级通道的关系
|
||||
//
|
||||
// 不新造安装逻辑:
|
||||
// · 插件产物交给 pluginmgr.installFromPath(保留配置、原地覆盖)
|
||||
// · 内核重启走 sdk.KernelControlAPI(guard 重拉,不经 systemd)
|
||||
//
|
||||
// · 插件产物交给 pluginmgr.installFromPath(保留配置、原地覆盖)
|
||||
// · 内核重启走 sdk.KernelControlAPI(guard 重拉,不经 systemd)
|
||||
//
|
||||
// 本包只管到"产物就绪"为止。
|
||||
package update
|
||||
|
||||
@ -247,7 +247,7 @@ func FileSize(p string) int64 {
|
||||
//
|
||||
// 每一步失败都往回退,且**回滚后必须能正常启动**——这是本函数的核心承诺:
|
||||
// 宁可升级失败,不可让机器起不来。
|
||||
func DoUpgrade(root, binPath, newDG, pluginsDir string) error {
|
||||
func DoUpgrade(root, binPath, newDG, pluginsDir, dataDir string) error {
|
||||
newBin := FindFile(root, "homed")
|
||||
if newBin == "" {
|
||||
return fmt.Errorf("包内无 homed")
|
||||
@ -277,8 +277,39 @@ func DoUpgrade(root, binPath, newDG, pluginsDir string) error {
|
||||
}
|
||||
fmt.Printf("已备份 %d 个文件 → %s\n", len(targets), backup)
|
||||
|
||||
// ── 写待确认升级标记(在替换**之前**)──
|
||||
//
|
||||
// 顺序很关键:先写标记再替换。若反过来(替换后才写),"替换成功但
|
||||
// 没来得及写标记就崩了"会丢标记 ⇒ 下次启动不知道该回滚。
|
||||
//
|
||||
// 标记由 update 模块(第 0 层)在下次启动时读;读到就说明上次升级
|
||||
// 留下的版本没能活到清除标记 ⇒ 回滚(见 internal/update/rollback.go)。
|
||||
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 fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr)
|
||||
}
|
||||
fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir))
|
||||
}
|
||||
|
||||
rollback := func(cause error) error {
|
||||
fmt.Printf("\n❌ %v\n== 开始回滚 ==\n", cause)
|
||||
if dataDir != "" {
|
||||
if cerr := ClearPendingUpgrade(dataDir); cerr != nil {
|
||||
fmt.Printf(" ⚠️ 清除升级确认标记失败: %v\n", cerr)
|
||||
}
|
||||
}
|
||||
for _, t := range targets {
|
||||
src := filepath.Join(backup, filepath.Base(t))
|
||||
if err := CopyFile(src, t); err != nil {
|
||||
|
||||
@ -96,11 +96,11 @@ func TestNewer(t *testing.T) {
|
||||
// 判据 3b:TrimTag 的归一语义。
|
||||
func TestTrimTag(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"v1.4.4": "1.4.4",
|
||||
"1.4.4": "1.4.4",
|
||||
"1.4.0dev": "1.4.0",
|
||||
"v1.4.4": "1.4.4",
|
||||
"1.4.4": "1.4.4",
|
||||
"1.4.0dev": "1.4.0",
|
||||
"1.4.0dev.1": "1.4.0",
|
||||
"release1": "release1", // 非纯版本原样返回,交给 ParseSemver 判不可比
|
||||
"release1": "release1", // 非纯版本原样返回,交给 ParseSemver 判不可比
|
||||
} {
|
||||
if got := TrimTag(in); got != want {
|
||||
t.Errorf("TrimTag(%q) = %q,期望 %q", in, got, want)
|
||||
|
||||
173
internal/update/rollback.go
Normal file
173
internal/update/rollback.go
Normal file
@ -0,0 +1,173 @@
|
||||
package update
|
||||
|
||||
// 升级确认与回滚(内核启动时的自愈逻辑)。
|
||||
//
|
||||
// ## 问题:升级后内核起不来,机器进崩溃循环
|
||||
//
|
||||
// 升级 = 换二进制 + 重启。若新二进制有缺陷(依赖图不匹配、协议变了、
|
||||
// 某个模块 Init 失败),worker 会反复崩溃,guard 只能靠 failback(受限启动)
|
||||
// 兜底 —— 那是最后手段,不该承担"换错二进制"这种**已知可回滚**的故障。
|
||||
//
|
||||
// ## 解法:两段式确认
|
||||
//
|
||||
// 升级成功 ──→ 写 pending 标记("这个版本尚未被确认能启动")
|
||||
// ↓
|
||||
// 重启 → 内核启动 → update 模块(第 0 层)读标记
|
||||
// ↓
|
||||
// ┌────────────┴────────────┐
|
||||
// 标记还在 标记已不在
|
||||
// (上次升级后没能活到清除) (上次升级已确认成功)
|
||||
// ↓ ↓
|
||||
// 回滚到备份版本 正常继续
|
||||
// 并清除标记
|
||||
//
|
||||
// 清除时机:update 模块跑完自检后立刻清。它只依赖"内核进程活着且能执行到
|
||||
// 第 0 层",不依赖任何其他模块 ⇒ 只要内核基本可用就能清除,不会误判。
|
||||
//
|
||||
// ## 为什么不用 TTL / 重启次数
|
||||
//
|
||||
// 那些是 guard 已有的机制(failback),但判据是"崩了几次",不是"这次升级
|
||||
// 该不该撤销"。用重启次数会在"新版本偶发崩溃"时误回滚 —— 而那种情况该修的
|
||||
// 是崩溃,不是回退版本。这里只认一个事实:**上次升级留下的版本没能启动成功**。
|
||||
|
||||
import (
|
||||
"log"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PendingFileName 是待确认升级标记的文件名(位于 dataDir 下)。
|
||||
const PendingFileName = "kernel-upgrade-pending.json"
|
||||
|
||||
// PendingRecord 是待确认升级标记的内容。
|
||||
type PendingRecord struct {
|
||||
// FromVer / FromCommit 是升级**前**的版本(回滚目标)。
|
||||
FromVer string `json:"from_version"`
|
||||
FromCommit string `json:"from_commit"`
|
||||
// ToVer / ToCommit 是升级**后**的版本(当前正在试的)。
|
||||
ToVer string `json:"to_version"`
|
||||
ToCommit string `json:"to_commit"`
|
||||
// BackupDir 是升级前备份目录(DoUpgrade 创建),回滚从这里取二进制。
|
||||
BackupDir string `json:"backup_dir"`
|
||||
// At 是写入时间(诊断用)。
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// pendingPath 返回标记文件路径。
|
||||
func pendingPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, PendingFileName)
|
||||
}
|
||||
|
||||
// MarkPendingUpgrade 写入待确认标记(升级成功后立刻调用)。
|
||||
//
|
||||
// 由 DoUpgrade 在替换二进制**之前**写:若替换后重启失败,标记一定在。
|
||||
// 反过来(替换后才写)会在"替换成功但没来得及写标记就崩了"时丢标记,
|
||||
// 于是下次启动不知道该回滚。
|
||||
func MarkPendingUpgrade(dataDir string, rec PendingRecord) error {
|
||||
if dataDir == "" {
|
||||
return fmt.Errorf("dataDir 为空,无法写升级确认标记")
|
||||
}
|
||||
rec.At = time.Now()
|
||||
b, err := json.MarshalIndent(rec, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := pendingPath(dataDir)
|
||||
if err := os.WriteFile(p, b, 0644); err != nil {
|
||||
return fmt.Errorf("写 %s: %w", p, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearPendingUpgrade 清除标记(版本已确认能启动)。
|
||||
func ClearPendingUpgrade(dataDir string) error {
|
||||
if dataDir == "" {
|
||||
return nil
|
||||
}
|
||||
p := pendingPath(dataDir)
|
||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("清除 %s: %w", p, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadPendingUpgrade 读标记;不存在返回 (nil, nil)。
|
||||
func ReadPendingUpgrade(dataDir string) (*PendingRecord, error) {
|
||||
b, err := os.ReadFile(pendingPath(dataDir))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var rec PendingRecord
|
||||
if err := json.Unmarshal(b, &rec); err != nil {
|
||||
return nil, fmt.Errorf("解析 %s: %w(删掉它即可跳过回滚)", pendingPath(dataDir), err)
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// RollbackIfUnconfirmed 在发现未确认升级时回滚二进制。
|
||||
//
|
||||
// 返回 (rolled, from, to, err):
|
||||
//
|
||||
// rolled=false, err=nil —— 没有待确认标记,正常继续
|
||||
// rolled=true, err=nil —— 已回滚(from/to 记录版本轨迹)
|
||||
// rolled=*, err!=nil —— 回滚失败,调用方**必须中止启动**
|
||||
// (继续装配等于用一个已知起不来的版本)
|
||||
func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error) {
|
||||
rec, err := ReadPendingUpgrade(dataDir)
|
||||
if err != nil {
|
||||
return false, "", "", err
|
||||
}
|
||||
if rec == nil {
|
||||
return false, "", "", nil
|
||||
}
|
||||
|
||||
log.Printf("[update] found unconfirmed upgrade: %s → %s(上次升级后内核未能启动成功)",
|
||||
ShortCommit(rec.FromCommit), ShortCommit(rec.ToCommit))
|
||||
|
||||
// 回滚源:升级前备份的 homed
|
||||
backupBin := filepath.Join(rec.BackupDir, "homed")
|
||||
if !FileExists(backupBin) {
|
||||
// 备份不在 ⇒ 无法回滚。此时**中止启动**而不是硬上:
|
||||
// 继续跑这个已知有问题的版本只会进崩溃循环。
|
||||
return false, "", "", fmt.Errorf(
|
||||
"备份不存在(%s):无法回滚。请人工用上一个已知良好版本替换 %s,"+
|
||||
"或删除 %s 后重试(放弃回滚)", backupBin, binPath, pendingPath(dataDir))
|
||||
}
|
||||
|
||||
// 先备份"当前这个坏的"——万一回滚目标也有问题,至少能回到现场
|
||||
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 {
|
||||
// 备份失败不阻断回滚:回滚是主线,留现场是支线
|
||||
log.Printf("[update] backup of broken binary failed: %v", err)
|
||||
} else {
|
||||
log.Printf("[update] broken binary kept at %s", brokenDir)
|
||||
}
|
||||
|
||||
// 原子替换回旧版
|
||||
if err := CopyFile(backupBin, binPath); err != nil {
|
||||
return false, "", "", fmt.Errorf("回滚二进制: %w", err)
|
||||
}
|
||||
|
||||
// 回滚 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 清标记:已回到旧版,不再有"待确认的新版"
|
||||
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)
|
||||
return true, rec.FromVer, rec.ToVer, nil
|
||||
}
|
||||
245
internal/update/rollback_test.go
Normal file
245
internal/update/rollback_test.go
Normal file
@ -0,0 +1,245 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── 升级确认与回滚的判据 ────────────────────────────────────
|
||||
//
|
||||
// 这些钉住"启动失败自动回滚"这个核心承诺。它一旦失效,症状是
|
||||
// 升级后机器进崩溃循环、只能人工上机器救 —— 而那时用户看不到任何提示。
|
||||
|
||||
// 判据 1:写标记 → 能读回,字段不丢。
|
||||
func TestPendingMarker_RoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rec := PendingRecord{
|
||||
FromVer: "1.4.0", FromCommit: "aaa111",
|
||||
ToVer: "1.4.1", ToCommit: "bbb222",
|
||||
BackupDir: "/root/homed-upgrade-backup-x",
|
||||
}
|
||||
if err := MarkPendingUpgrade(dir, rec); err != nil {
|
||||
t.Fatalf("写标记: %v", err)
|
||||
}
|
||||
got, err := ReadPendingUpgrade(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("读标记: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("应读到标记")
|
||||
}
|
||||
if got.FromCommit != rec.FromCommit || got.ToCommit != rec.ToCommit || got.BackupDir != rec.BackupDir {
|
||||
t.Errorf("字段丢失: %+v", got)
|
||||
}
|
||||
if got.At.IsZero() {
|
||||
t.Error("At 应被自动填上(诊断用)")
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 2:没有标记时读回 (nil, nil) —— 不是 error。
|
||||
//
|
||||
// 这是绝大多数启动的形态(没有待确认升级)。若这里返回 error,
|
||||
// 每次启动都会走"回滚失败"分支 ⇒ 内核永远起不来。
|
||||
func TestPendingMarker_AbsentIsNotError(t *testing.T) {
|
||||
got, err := ReadPendingUpgrade(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("无标记不应报错: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("无标记应返回 nil,实际 %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 3:清标记后再读应为 nil。
|
||||
func TestPendingMarker_Clear(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := MarkPendingUpgrade(dir, PendingRecord{FromVer: "a", ToVer: "b"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ClearPendingUpgrade(dir); err != nil {
|
||||
t.Fatalf("清标记: %v", err)
|
||||
}
|
||||
if got, _ := ReadPendingUpgrade(dir); got != nil {
|
||||
t.Errorf("清后应读不到,实际 %+v", got)
|
||||
}
|
||||
// 幂等:再清一次不报错(update 模块每次启动都调)
|
||||
if err := ClearPendingUpgrade(dir); err != nil {
|
||||
t.Errorf("重复清除应幂等: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 4:RollbackIfUnconfirmed 在无标记时不动二进制。
|
||||
func TestRollback_NoMarkerNoOp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "homed")
|
||||
os.WriteFile(bin, []byte("current"), 0755)
|
||||
|
||||
rolled, _, _, err := RollbackIfUnconfirmed(dir, bin)
|
||||
if err != nil {
|
||||
t.Fatalf("无标记不应报错: %v", err)
|
||||
}
|
||||
if rolled {
|
||||
t.Error("无标记不应触发回滚")
|
||||
}
|
||||
b, _ := os.ReadFile(bin)
|
||||
if string(b) != "current" {
|
||||
t.Errorf("二进制不应被改动,实际 %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 5:有标记且备份存在 ⇒ 回滚,二进制回到旧版。
|
||||
//
|
||||
// 这是核心路径:上次升级留下的版本没能启动成功。
|
||||
func TestRollback_RestoresBackup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "homed")
|
||||
|
||||
// 备份目录里放"旧版"
|
||||
backupDir := filepath.Join(dir, "backup")
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
oldBin := filepath.Join(backupDir, "homed")
|
||||
os.WriteFile(oldBin, []byte("OLD-GOOD"), 0755)
|
||||
os.WriteFile(filepath.Join(backupDir, "depgraph.bin"), []byte("OLD-DG"), 0644)
|
||||
|
||||
// 当前是"升级后的坏版本"
|
||||
os.WriteFile(bin, []byte("NEW-BROKEN"), 0755)
|
||||
os.WriteFile(filepath.Join(dir, "depgraph.bin"), []byte("NEW-DG"), 0644)
|
||||
|
||||
MarkPendingUpgrade(dir, PendingRecord{
|
||||
FromVer: "1.4.0", FromCommit: "old",
|
||||
ToVer: "1.4.1", ToCommit: "new",
|
||||
BackupDir: backupDir,
|
||||
})
|
||||
|
||||
rolled, from, to, err := RollbackIfUnconfirmed(dir, bin)
|
||||
if err != nil {
|
||||
t.Fatalf("回滚不应失败: %v", err)
|
||||
}
|
||||
if !rolled {
|
||||
t.Fatal("有待确认标记时必须回滚")
|
||||
}
|
||||
if from != "1.4.0" || to != "1.4.1" {
|
||||
t.Errorf("版本轨迹应记录 from/to,实际 %s → %s", from, to)
|
||||
}
|
||||
|
||||
// ① 二进制回到旧版
|
||||
b, _ := os.ReadFile(bin)
|
||||
if string(b) != "OLD-GOOD" {
|
||||
t.Errorf("二进制应回滚到旧版,实际 %q", b)
|
||||
}
|
||||
// ② depgraph 同批回滚(必须与 homed 一致)
|
||||
dg, _ := os.ReadFile(filepath.Join(dir, "depgraph.bin"))
|
||||
if string(dg) != "OLD-DG" {
|
||||
t.Errorf("depgraph 应同批回滚,实际 %q", dg)
|
||||
}
|
||||
// ③ 标记已清(否则下次启动又回滚一次)
|
||||
if got, _ := ReadPendingUpgrade(dir); got != nil {
|
||||
t.Error("回滚后应清除标记")
|
||||
}
|
||||
// ④ 坏版本被留档(万一回滚目标也有问题,能回到现场)
|
||||
entries, _ := os.ReadDir(dir)
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if len(e.Name()) > 12 && e.Name()[:12] == "homed-broken" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("坏版本应被留档到 homed-broken-*")
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 6:备份不存在 ⇒ 回滚失败(返回 error),**不静默继续**。
|
||||
//
|
||||
// 静默继续的后果:用一个已知起不来的版本装配 ⇒ 崩溃循环。
|
||||
// 必须让调用方中止启动,把问题暴露给人。
|
||||
func TestRollback_MissingBackupIsLoud(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "homed")
|
||||
os.WriteFile(bin, []byte("NEW-BROKEN"), 0755)
|
||||
|
||||
MarkPendingUpgrade(dir, PendingRecord{
|
||||
FromVer: "1.4.0", BackupDir: filepath.Join(dir, "nonexistent-backup"),
|
||||
})
|
||||
|
||||
rolled, _, _, err := RollbackIfUnconfirmed(dir, bin)
|
||||
if err == nil {
|
||||
t.Fatal("备份不存在时必须报错(静默继续 = 用已知起不来的版本装配)")
|
||||
}
|
||||
if rolled {
|
||||
t.Error("回滚失败时 rolled 应为 false")
|
||||
}
|
||||
// 错误必须可执行:说出备份路径与标记路径,人能照着修
|
||||
if !containsStr2(err.Error(), "nonexistent-backup") {
|
||||
t.Errorf("错误应指出备份路径,实际: %v", err)
|
||||
}
|
||||
if !containsStr2(err.Error(), "kernel-upgrade-pending") {
|
||||
t.Errorf("错误应指出标记路径(删它即可放弃回滚),实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 7:标记写坏(JSON 损坏)⇒ 报错并提示可删。
|
||||
func TestPendingMarker_CorruptIsActionable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := pendingPath(dir)
|
||||
os.WriteFile(p, []byte("{ not json"), 0644)
|
||||
|
||||
_, err := ReadPendingUpgrade(dir)
|
||||
if err == nil {
|
||||
t.Fatal("损坏的标记必须报错")
|
||||
}
|
||||
if !containsStr2(err.Error(), "删掉") {
|
||||
t.Errorf("错误应说明可删掉该文件,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr2(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 判据 8:State.Summary 必须带回滚信息(healthcheck 要展示)。
|
||||
func TestStateSummary_CarriesRollback(t *testing.T) {
|
||||
st := &State{
|
||||
Version: "1.4.0", Commit: "abc123", SDKVersion: "1.3.0",
|
||||
RolledBack: true, RolledFrom: "1.4.0", RolledTo: "1.4.1",
|
||||
}
|
||||
m := st.Summary()
|
||||
if m["rolled_back"] != true {
|
||||
t.Error("Summary 应带回滚标记")
|
||||
}
|
||||
if m["rolled_from"] != "1.4.0" || m["rolled_to"] != "1.4.1" {
|
||||
t.Errorf("Summary 应带回滚轨迹,实际 %v", m)
|
||||
}
|
||||
// ShortCommit 只对**超过 8 位**的 commit 截短;6 位的原样返回。
|
||||
// 用长 commit 才能验到截短行为。
|
||||
st2 := &State{Version: "1.4.0", Commit: "abcdef1234567890"}
|
||||
if m2 := st2.Summary(); m2["commit"] != "abcdef12" {
|
||||
t.Errorf("长 commit 应被截到 8 位,实际 %v", m2["commit"])
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 9:nil State 的 Summary 不 panic(healthcheck 可能在模块未就绪时调用)。
|
||||
func TestStateSummary_NilSafe(t *testing.T) {
|
||||
var st *State
|
||||
if got := st.Summary(); got != nil {
|
||||
t.Errorf("nil State 应返回 nil,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 10:标记里的时间会被填上(诊断"这次升级是什么时候试的")。
|
||||
func TestPendingMarker_TimestampSet(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
before := time.Now().Add(-time.Second)
|
||||
MarkPendingUpgrade(dir, PendingRecord{FromVer: "a", ToVer: "b"})
|
||||
got, _ := ReadPendingUpgrade(dir)
|
||||
if got.At.Before(before) {
|
||||
t.Errorf("At 应接近当前时间,实际 %v", got.At)
|
||||
}
|
||||
}
|
||||
63
internal/update/state.go
Normal file
63
internal/update/state.go
Normal file
@ -0,0 +1,63 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// State 是更新子系统的公共数据(挂在 KernelState.Update 上,由 update 模块填充)。
|
||||
//
|
||||
// 只放**别人要读**的东西:版本、是否回滚过、二进制路径。
|
||||
// 更新实现(Check/Upgrade/DoUpgrade)是函数,不需要常驻状态。
|
||||
//
|
||||
// ★ 定义在 internal/update 而不是 kernelmod:三端(CLI/WebAPI/agent 工具)
|
||||
// 都要用,而 KernelState 只有内核启动一条路径。模块通过
|
||||
// kernelboot.UpdateStateProvider 接口把它暴露给内核,避免 import 循环。
|
||||
type State struct {
|
||||
BinaryPath string
|
||||
DepGraphPath string
|
||||
Version string
|
||||
Commit string
|
||||
SDKVersion string
|
||||
ProbeErr error
|
||||
|
||||
// RolledBack 表示本次启动是否回滚了上一次升级。
|
||||
RolledBack bool
|
||||
RolledFrom string
|
||||
RolledTo string
|
||||
}
|
||||
|
||||
// Summary 返回给 healthcheck / 状态面的摘要。
|
||||
func (s *State) Summary() map[string]interface{} {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"version": s.Version,
|
||||
"commit": ShortCommit(s.Commit),
|
||||
"sdk_version": s.SDKVersion,
|
||||
"binary": s.BinaryPath,
|
||||
"rolled_back": s.RolledBack,
|
||||
}
|
||||
if s.ProbeErr != nil {
|
||||
out["probe_error"] = s.ProbeErr.Error()
|
||||
}
|
||||
if s.RolledBack {
|
||||
out["rolled_from"], out["rolled_to"] = s.RolledFrom, s.RolledTo
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// KernelBinaryPath 返回本进程的内核二进制路径。
|
||||
//
|
||||
// 用 os.Executable 而不是硬编码 /usr/local/bin/homed:部署路径可变
|
||||
// (install.sh 支持 PREFIX),而"我自己是谁"永远可靠。
|
||||
func KernelBinaryPath() string {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
if resolved, err2 := filepath.EvalSymlinks(exe); err2 == nil {
|
||||
return resolved
|
||||
}
|
||||
return exe
|
||||
}
|
||||
return "homed"
|
||||
}
|
||||
Reference in New Issue
Block a user