From c286d4d2dfbe4a2dfca295b8396b5a12f03e06f2 Mon Sep 17 00:00:00 2001 From: qian Date: Sat, 15 Aug 2026 13:56:55 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(update):=E4=BF=AE=E5=A4=8D=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E6=9B=B4=E6=96=B0=E5=A4=9A=E4=B8=AA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/update/checker_test.go | 80 ++++++++++ internal/logic/update/docker_test.go | 44 ++++++ internal/logic/update/replacer.go | 11 +- internal/logic/update/replacer_test.go | 200 +++++++++++++++++++++++++ internal/logic/update/types.go | 10 ++ internal/logic/update/types_test.go | 157 +++++++++++++++++++ internal/logic/update/update.go | 85 +++++++++-- internal/logic/update/verifier.go | 11 +- internal/logic/update/verifier_test.go | 126 ++++++++++++++++ web/admin/src/views/AdminLayout.vue | 9 +- 10 files changed, 705 insertions(+), 28 deletions(-) create mode 100644 internal/logic/update/checker_test.go create mode 100644 internal/logic/update/docker_test.go create mode 100644 internal/logic/update/replacer_test.go create mode 100644 internal/logic/update/types_test.go create mode 100644 internal/logic/update/verifier_test.go diff --git a/internal/logic/update/checker_test.go b/internal/logic/update/checker_test.go new file mode 100644 index 0000000..4619cfd --- /dev/null +++ b/internal/logic/update/checker_test.go @@ -0,0 +1,80 @@ +package update + +import ( + "fmt" + "runtime" + "strings" + "testing" +) + +func TestCompareVersions(t *testing.T) { + cases := []struct { + name string + current string + latest string + want bool + wantErr bool + }{ + {"落后于最新版", "1.0.0", "1.0.1", true, false}, + {"小幅落后大版本", "1.9.9", "2.0.0", true, false}, + {"版本相同", "1.0.0", "1.0.0", false, false}, + {"当前更新", "1.2.0", "1.1.0", false, false}, + {"latest 带 v 前缀", "1.0.0", "v1.0.1", true, false}, + {"dev 构建保守提示更新", "dev", "0.0.1", true, false}, + {"当前版本无法解析按 dev 处理", "garbage", "0.0.1", true, false}, + {"git describe 版本与基础版本等价", "0.2.0_39", "0.2.0", false, false}, + {"git describe 版本落后于新版", "0.2.0_39", "0.2.1", true, false}, + {"latest 无法解析报错", "1.0.0", "not-a-version", false, true}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := compareVersions(c.current, c.latest) + if c.wantErr { + if err == nil { + t.Fatalf("latest=%q 无法解析时应返回错误", c.latest) + } + return + } + if err != nil { + t.Fatalf("比较失败: %v", err) + } + if got != c.want { + t.Errorf("compareVersions(%q, %q) = %v, want %v", c.current, c.latest, got, c.want) + } + }) + } +} + +func TestNormalizeSemver(t *testing.T) { + cases := []struct{ in, want string }{ + {"0.2.0_39", "0.2.0+39"}, // Makefile git describe 版本:下划线转构建元数据 + {"v1.2.3", "1.2.3"}, // v 前缀剥离 + {"1.2.3", "1.2.3"}, // 原样保留 + {"dev", "dev"}, + } + for _, c := range cases { + if got := normalizeSemver(c.in); got != c.want { + t.Errorf("normalizeSemver(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestGetPlatformAssetName(t *testing.T) { + ext := "tar.gz" + if runtime.GOOS == "windows" { + ext = "zip" + } + + // v 前缀应被剥离 + got := getPlatformAssetName("v1.2.3") + want := fmt.Sprintf("team-api-1.2.3-%s-%s.%s", runtime.GOOS, runtime.GOARCH, ext) + if got != want { + t.Errorf("getPlatformAssetName(v1.2.3) = %q, want %q", got, want) + } + + // 无前缀原样使用 + if got := getPlatformAssetName("2.0.0"); !strings.Contains(got, "team-api-2.0.0-") { + t.Errorf("getPlatformAssetName(2.0.0) = %q, 应包含 team-api-2.0.0-", got) + } +} diff --git a/internal/logic/update/docker_test.go b/internal/logic/update/docker_test.go new file mode 100644 index 0000000..5c70030 --- /dev/null +++ b/internal/logic/update/docker_test.go @@ -0,0 +1,44 @@ +package update + +import "testing" + +func TestContainsDockerIndicator(t *testing.T) { + cases := []struct { + name string + content string + want bool + }{ + {"docker cgroup 路径", "0::/docker/abc123", true}, + {"docker- 前缀", "12:pids:/docker-abc", true}, + {"包含 docker 字样", "1:name=systemd/docker.slice", true}, + {"k8s 环境", "0::/kubepods/pod123", false}, + {"普通 cgroup", "0::/user.slice/user-1000.slice", false}, + {"空内容", "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := containsDockerIndicator(c.content); got != c.want { + t.Errorf("containsDockerIndicator(%q) = %v, want %v", c.content, got, c.want) + } + }) + } +} + +func TestContains(t *testing.T) { + cases := []struct { + s, sub string + want bool + }{ + {"hello docker world", "docker", true}, + {"hello docker world", "world", true}, + {"hello", "hello", true}, // 与自身相等 + {"abc", "abcd", false}, // 子串比源串长 + {"", "", true}, // 双空串 + {"abc", "", true}, // 空子串视为匹配(containsDockerIndicator 已另行过滤空内容) + } + for _, c := range cases { + if got := contains(c.s, c.sub); got != c.want { + t.Errorf("contains(%q, %q) = %v, want %v", c.s, c.sub, got, c.want) + } + } +} diff --git a/internal/logic/update/replacer.go b/internal/logic/update/replacer.go index 0f6b6ac..4b7362e 100644 --- a/internal/logic/update/replacer.go +++ b/internal/logic/update/replacer.go @@ -76,9 +76,9 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL return } - // Step 2: Verify + // Step 2: Verify(校验失败一律中止,不放行未通过完整性校验的更新包) if err := VerifyChecksum(ctx, dlResult.FilePath, checksumURL, assetName); err != nil { - manager.setProgressError("文件校验失败", err.Error()) + manager.setProgressError("文件校验失败,更新包可能已破损", err.Error()) g.Log().Errorf(ctx, "Checksum verification failed: %v", err) _ = os.Remove(dlResult.FilePath) return @@ -143,11 +143,14 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL // Step 7: Write pending verification marker pendingData, _ := json.Marshal(struct { - Version string `json:"version"` - OldBinary string `json:"old_binary"` + Version string `json:"version"` + OldBinary string `json:"old_binary"` + OldVersion string `json:"old_version"` }{ Version: targetVersion, OldBinary: backupPath, + // 记录升级前版本号:自检完成后展示 rollback 入口时需要显示版本而非备份路径 + OldVersion: consts.Version, }) _ = os.WriteFile(filepath.Join(updateDir, pendingVerificationFile), pendingData, 0644) diff --git a/internal/logic/update/replacer_test.go b/internal/logic/update/replacer_test.go new file mode 100644 index 0000000..720a67d --- /dev/null +++ b/internal/logic/update/replacer_test.go @@ -0,0 +1,200 @@ +package update + +import ( + "archive/tar" + "compress/gzip" + "context" + "os" + "path/filepath" + "runtime" + "testing" +) + +// createTarball 在临时目录生成包含指定文件的 tar.gz,返回其路径。 +// files 的 key 为归档内路径,value 为文件内容。 +func createTarball(t *testing.T, files map[string]string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "asset.tar.gz") + + f, err := os.Create(path) + if err != nil { + t.Fatalf("创建 tarball 失败: %v", err) + } + defer f.Close() + + gzw := gzip.NewWriter(f) + defer gzw.Close() + + tw := tar.NewWriter(gzw) + defer tw.Close() + + for name, content := range files { + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0o755, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + }); err != nil { + t.Fatalf("写 tar header 失败: %v", err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatalf("写 tar 内容失败: %v", err) + } + } + return path +} + +func TestExtractBinary_正常提取(t *testing.T) { + tarball := createTarball(t, map[string]string{ + "team-api-1.0.0-linux-amd64/team-api": "new-binary-content", + "team-api-1.0.0-linux-amd64/README.md": "docs", + }) + + outPath := filepath.Join(t.TempDir(), "team-api.new") + got, err := extractBinary(context.Background(), tarball, outPath) + if err != nil { + t.Fatalf("提取二进制失败: %v", err) + } + if got != outPath { + t.Errorf("返回路径应为 %s,实际 %s", outPath, got) + } + + data, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("读取提取结果失败: %v", err) + } + if string(data) != "new-binary-content" { + t.Errorf("提取内容不匹配: %q", string(data)) + } + + // 提取出的二进制必须带可执行权限(Windows 不支持 Unix 权限位,跳过) + if runtime.GOOS != "windows" { + info, err := os.Stat(outPath) + if err != nil { + t.Fatalf("stat 提取结果失败: %v", err) + } + if info.Mode().Perm()&0100 == 0 { + t.Errorf("提取的二进制缺少可执行权限: %v", info.Mode().Perm()) + } + } +} + +func TestExtractBinary_归档中无二进制(t *testing.T) { + tarball := createTarball(t, map[string]string{ + "team-api-1.0.0-linux-amd64/README.md": "docs", + }) + + outPath := filepath.Join(t.TempDir(), "team-api.new") + if _, err := extractBinary(context.Background(), tarball, outPath); err == nil { + t.Fatal("归档中不含 team-api 二进制时应返回错误") + } +} + +func TestReplaceBinary_正常替换(t *testing.T) { + dir := t.TempDir() + current := filepath.Join(dir, "team-api") + newBin := filepath.Join(dir, "team-api.new") + + if err := os.WriteFile(current, []byte("old"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(newBin, []byte("new"), 0755); err != nil { + t.Fatal(err) + } + + if err := replaceBinary(context.Background(), current, newBin); err != nil { + t.Fatalf("替换失败: %v", err) + } + + // 新内容应已就位 + data, _ := os.ReadFile(current) + if string(data) != "new" { + t.Errorf("替换后二进制内容应为 new,实际 %q", string(data)) + } + // 旧版本应被挪到 .old + oldData, _ := os.ReadFile(current + ".old") + if string(oldData) != "old" { + t.Errorf("旧二进制应保留在 .old,实际 %q", string(oldData)) + } + // 替换后必须可执行(Windows 不支持 Unix 权限位,跳过) + if runtime.GOOS != "windows" { + info, _ := os.Stat(current) + if info.Mode().Perm()&0100 == 0 { + t.Errorf("替换后的二进制缺少可执行权限: %v", info.Mode().Perm()) + } + } +} + +func TestReplaceBinary_残留old文件被清理(t *testing.T) { + dir := t.TempDir() + current := filepath.Join(dir, "team-api") + newBin := filepath.Join(dir, "team-api.new") + + for path, content := range map[string]string{current: "old", newBin: "new", current + ".old": "stale"} { + if err := os.WriteFile(path, []byte(content), 0755); err != nil { + t.Fatal(err) + } + } + + if err := replaceBinary(context.Background(), current, newBin); err != nil { + t.Fatalf("替换失败: %v", err) + } + + oldData, _ := os.ReadFile(current + ".old") + if string(oldData) != "old" { + t.Errorf("残留 .old 应被清理并被当前旧版本覆盖,实际 %q", string(oldData)) + } +} + +func TestReplaceBinary_失败时自救恢复(t *testing.T) { + dir := t.TempDir() + current := filepath.Join(dir, "team-api") + + if err := os.WriteFile(current, []byte("old"), 0755); err != nil { + t.Fatal(err) + } + + // newBinary 不存在:第二步 rename 失败,应把 current 从 .old 恢复回来 + missing := filepath.Join(dir, "not-exist.new") + if err := replaceBinary(context.Background(), current, missing); err == nil { + t.Fatal("新二进制缺失时替换应返回错误") + } + + data, _ := os.ReadFile(current) + if string(data) != "old" { + t.Errorf("替换失败后当前二进制应恢复原内容,实际 %q", string(data)) + } + if _, err := os.Stat(current + ".old"); !os.IsNotExist(err) { + t.Error("自救恢复后 .old 不应残留") + } +} + +func TestCopyFile_内容与权限一致(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + + if err := os.WriteFile(src, []byte("binary-bytes"), 0755); err != nil { + t.Fatal(err) + } + + if err := copyFile(src, dst); err != nil { + t.Fatalf("复制失败: %v", err) + } + + data, _ := os.ReadFile(dst) + if string(data) != "binary-bytes" { + t.Errorf("复制内容不匹配: %q", string(data)) + } + srcInfo, _ := os.Stat(src) + dstInfo, _ := os.Stat(dst) + if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { + t.Errorf("复制后权限不一致: src=%v dst=%v", srcInfo.Mode().Perm(), dstInfo.Mode().Perm()) + } +} + +func TestCopyFile_源文件不存在(t *testing.T) { + if err := copyFile(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dst")); err == nil { + t.Fatal("源文件不存在时应返回错误") + } +} diff --git a/internal/logic/update/types.go b/internal/logic/update/types.go index 689eee5..40bcf86 100644 --- a/internal/logic/update/types.go +++ b/internal/logic/update/types.go @@ -3,6 +3,7 @@ package update import ( "sync" "sync/atomic" + "time" "github.com/gogf/gf/v2/os/gtime" ) @@ -65,6 +66,15 @@ type Progress struct { Message string `json:"message"` Percentage int `json:"percentage"` Error string `json:"error,omitempty"` + // FinishedAt 终态(complete/failed)产生时间。终态进度只在内存中保留一段 + // 时间供发起升级的管理员查看,超过 progressExpire 后 GetStatus 不再返回, + // 避免上次升级留下的快照永久残留误导后续状态判断 + FinishedAt *time.Time `json:"finished_at,omitempty"` +} + +// isTerminalPhase 判断进度是否处于终态(complete/failed),只有终态会过期 +func isTerminalPhase(phase string) bool { + return phase == PhaseComplete || phase == PhaseFailed } // RollbackInfo holds information needed for rollback (persisted to disk) diff --git a/internal/logic/update/types_test.go b/internal/logic/update/types_test.go new file mode 100644 index 0000000..d4ca3eb --- /dev/null +++ b/internal/logic/update/types_test.go @@ -0,0 +1,157 @@ +package update + +import ( + "testing" + "time" +) + +// 使用独立实例而非全局单例,避免测试间状态污染 +func newTestManager() *UpdateManager { + return &UpdateManager{} +} + +func TestUpdateManager_默认状态(t *testing.T) { + m := newTestManager() + + status := m.GetStatus() + if status.Updating { + t.Error("初始状态不应处于更新中") + } + if status.Progress != nil { + t.Errorf("初始状态不应有进度信息,实际 %+v", status.Progress) + } + if m.IsUpdating() { + t.Error("初始 updating 锁应为 false") + } + if m.GetCheckResult() != nil { + t.Error("初始检查结果应为空") + } +} + +func TestUpdateManager_进度状态机(t *testing.T) { + m := newTestManager() + + m.setProgress(PhaseDownloading, "正在下载...", 50) + status := m.GetStatus() + if !status.Updating { + t.Error("设置进度后应处于更新中") + } + if status.Progress == nil || status.Progress.Phase != PhaseDownloading || status.Progress.Percentage != 50 { + t.Errorf("进度未正确记录: %+v", status.Progress) + } + + // 多次 setProgress 应覆盖同一份状态快照 + m.setProgress(PhaseVerifying, "正在校验...", 80) + if p := m.GetStatus().Progress; p.Phase != PhaseVerifying || p.Percentage != 80 { + t.Errorf("进度覆盖失败: %+v", p) + } +} + +func TestUpdateManager_失败收尾(t *testing.T) { + m := newTestManager() + + m.setProgress(PhaseDownloading, "正在下载...", 50) + m.setProgressError("文件校验失败,更新包可能已破损", "SHA256 校验不一致") + + status := m.GetStatus() + if status.Updating { + t.Error("失败后 Updating 应复位为 false") + } + if status.Progress == nil || status.Progress.Phase != PhaseFailed { + t.Fatalf("失败后进度应标记 PhaseFailed: %+v", status.Progress) + } + if status.Progress.Message != "文件校验失败,更新包可能已破损" { + t.Errorf("失败提示未记录: %q", status.Progress.Message) + } + if status.Progress.Error == "" { + t.Error("失败详情未记录") + } + if m.IsUpdating() { + t.Error("失败后 updating 锁应释放,允许再次发起更新") + } +} + +func TestUpdateManager_更新锁互斥(t *testing.T) { + m := newTestManager() + + if !m.updating.CompareAndSwap(false, true) { + t.Fatal("首次抢锁应成功") + } + if m.updating.CompareAndSwap(false, true) { + t.Fatal("更新进行中再次抢锁应失败") + } + if !m.IsUpdating() { + t.Error("抢锁后 IsUpdating 应为 true") + } + + m.updating.Store(false) + if m.IsUpdating() { + t.Error("释放后 IsUpdating 应为 false") + } +} + +func TestGetStatus_终态进度过期自动失效(t *testing.T) { + m := newTestManager() + + // 上次升级留下的过期 complete 快照:不应再下发 + stale := time.Now().Add(-(progressExpire + time.Minute)) + m.status.Store(&Status{ + CurrentVersion: "0.2.7", + Updating: false, + Progress: &Progress{Phase: PhaseComplete, Message: "更新完成", Percentage: 100, FinishedAt: &stale}, + }) + + status := m.GetStatus() + if status.Progress != nil { + t.Errorf("过期的终态进度不应返回: %+v", status.Progress) + } + if status.CurrentVersion != "0.2.7" { + t.Errorf("剔除过期进度不应影响其余状态字段: %+v", status) + } + + // 过期的 failed 同样失效 + m.status.Store(&Status{ + Updating: false, + Progress: &Progress{Phase: PhaseFailed, Message: "失败", FinishedAt: &stale}, + }) + if got := m.GetStatus().Progress; got != nil { + t.Errorf("过期的 failed 进度不应返回: %+v", got) + } +} + +func TestGetStatus_终态进度窗口内保留(t *testing.T) { + m := newTestManager() + + // 刚完成的 complete:管理员仍在轮询确认窗口内,应正常返回 + fresh := time.Now().Add(-1 * time.Minute) + m.status.Store(&Status{ + Updating: false, + Progress: &Progress{Phase: PhaseComplete, Message: "更新完成", Percentage: 100, FinishedAt: &fresh}, + }) + + status := m.GetStatus() + if status.Progress == nil || status.Progress.Phase != PhaseComplete { + t.Errorf("窗口内的终态进度应保留: %+v", status.Progress) + } +} + +func TestGetStatus_非终态进度不过期(t *testing.T) { + m := newTestManager() + + // 进行中的进度(无 FinishedAt)不受过期机制影响 + m.status.Store(&Status{ + Updating: true, + Progress: &Progress{Phase: PhaseDownloading, Message: "正在下载...", Percentage: 50}, + }) + + status := m.GetStatus() + if status.Progress == nil || status.Progress.Phase != PhaseDownloading { + t.Errorf("进行中的进度不应被剔除: %+v", status.Progress) + } + + // setProgressError 写入的 failed 带时间戳,窗口内可读 + m.setProgressError("失败", "boom") + if got := m.GetStatus().Progress; got == nil || got.Phase != PhaseFailed || got.FinishedAt == nil { + t.Errorf("setProgressError 应写入带时间戳的 failed 进度: %+v", got) + } +} diff --git a/internal/logic/update/update.go b/internal/logic/update/update.go index ab9d999..7da0dc5 100644 --- a/internal/logic/update/update.go +++ b/internal/logic/update/update.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "fmt" + "net" "os" "path/filepath" "runtime" + "strconv" "time" "github.com/gogf/gf/v2/frame/g" @@ -23,6 +25,9 @@ const ( pendingVerificationFile = "pending_verification" // backupMaxAge is how long old backups are kept backupMaxAge = 7 * 24 * time.Hour + // progressExpire 终态进度(complete/failed)的保留时长: + // 覆盖一次升级完成后管理员轮询确认的窗口,超时即不再下发 + progressExpire = 30 * time.Minute ) // manager is the singleton UpdateManager @@ -49,6 +54,29 @@ func InitManager(ctx context.Context) { g.Log().Info(ctx, "Update manager initialized") } +// selfHealthURL 构造升级后自检用的本机健康检查地址。 +// 端口优先取 ghttp 运行时实际监听端口(GetListenedPort),服务尚未完成监听时 +// 从配置 server.address 兜底解析,避免部署自定义端口后仍探测默认的 18888。 +// 始终探测 127.0.0.1(GetListenedAddress 可能是 0.0.0.0/[::] 这类通配地址, +// 不能直接作为连接目标)。 +func selfHealthURL() string { + if port := g.Server().GetListenedPort(); port > 0 { + return fmt.Sprintf("http://127.0.0.1:%d/api/health", port) + } + + // 兜底:服务未开始监听(理论上自检在启动 5 秒后才开始,很难走到这里) + if addr := g.Cfg().MustGet(context.Background(), "server.address").String(); addr != "" { + if _, portStr, err := net.SplitHostPort(addr); err == nil { + if port, err := strconv.Atoi(portStr); err == nil && port > 0 { + return fmt.Sprintf("http://127.0.0.1:%d/api/health", port) + } + } + } + + // 最终兜底:默认端口 + return "http://127.0.0.1:18888/api/health" +} + // CheckPendingVerification checks if this process just started after an update // and verifies the new version is healthy func CheckPendingVerification(ctx context.Context) { @@ -59,8 +87,9 @@ func CheckPendingVerification(ctx context.Context) { } var info struct { - Version string `json:"version"` - OldBinary string `json:"old_binary"` + Version string `json:"version"` + OldBinary string `json:"old_binary"` + OldVersion string `json:"old_version"` } if err := json.Unmarshal(data, &info); err != nil { _ = os.Remove(pendingPath) @@ -73,14 +102,24 @@ func CheckPendingVerification(ctx context.Context) { go func() { time.Sleep(5 * time.Second) - // Check own health endpoint + // 回滚入口展示用的版本号:优先取 pending 标记里的旧版本号; + // 旧版升级流程写的标记没有该字段,回落读 rollback.json + backupVersion := info.OldVersion + if backupVersion == "" { + if rb, rbErr := loadRollbackInfo(); rbErr == nil && rb != nil { + backupVersion = rb.BackupVersion + } + } + + verifiedAt := time.Now() + // Check own health endpoint(端口取运行时实际监听端口,见 selfHealthURL 注释) client := g.Client() - resp, err := client.Get(ctx, "http://127.0.0.1:18888/api/health") + resp, err := client.Get(ctx, selfHealthURL()) if err != nil || resp == nil { g.Log().Warningf(ctx, "Update verification health check failed: %v", err) // Wait and retry time.Sleep(10 * time.Second) - resp, err = client.Get(ctx, "http://127.0.0.1:18888/api/health") + resp, err = client.Get(ctx, selfHealthURL()) } if err == nil && resp != nil { @@ -94,9 +133,9 @@ func CheckPendingVerification(ctx context.Context) { CurrentVersion: consts.Version, DeploymentMode: GetDeploymentMode(), Updating: false, - Progress: &Progress{Phase: PhaseComplete, Message: "更新完成", Percentage: 100}, + Progress: &Progress{Phase: PhaseComplete, Message: "更新完成", Percentage: 100, FinishedAt: &verifiedAt}, RollbackAvailable: info.OldBinary != "", - BackupVersion: info.OldBinary, + BackupVersion: backupVersion, } manager.status.Store(status) @@ -115,12 +154,13 @@ func CheckPendingVerification(ctx context.Context) { DeploymentMode: GetDeploymentMode(), Updating: false, Progress: &Progress{ - Phase: PhaseFailed, - Message: "更新后健康检查失败,建议回滚", - Error: "health check failed after update", + Phase: PhaseFailed, + Message: "更新后健康检查失败,建议回滚", + Error: "health check failed after update", + FinishedAt: &verifiedAt, }, RollbackAvailable: info.OldBinary != "", - BackupVersion: info.OldBinary, + BackupVersion: backupVersion, } manager.status.Store(status) }() @@ -141,7 +181,10 @@ func BackgroundCheck(ctx context.Context) error { return nil } -// GetStatus returns the current update status +// GetStatus returns the current update status. +// 终态进度(complete/failed)超过 progressExpire 后视为过期,返回时剔除, +// 避免上次升级留下的快照在进程内永久残留(快照本身保留在内存中不影响, +// 下一次 setProgress 会以无进度的状态重建) func (m *UpdateManager) GetStatus() *Status { val := m.status.Load() if val == nil { @@ -151,7 +194,15 @@ func (m *UpdateManager) GetStatus() *Status { Updating: false, } } - return val.(*Status) + status := val.(*Status) + + if p := status.Progress; p != nil && isTerminalPhase(p.Phase) && + p.FinishedAt != nil && time.Since(*p.FinishedAt) > progressExpire { + filtered := *status + filtered.Progress = nil + return &filtered + } + return status } // GetCheckResult returns the cached check result @@ -184,10 +235,12 @@ func (m *UpdateManager) setProgress(phase, message string, percentage int) { func (m *UpdateManager) setProgressError(message, errMsg string) { status := m.GetStatus() status.Updating = false + failedAt := time.Now() status.Progress = &Progress{ - Phase: PhaseFailed, - Message: message, - Error: errMsg, + Phase: PhaseFailed, + Message: message, + Error: errMsg, + FinishedAt: &failedAt, } m.status.Store(status) m.updating.Store(false) diff --git a/internal/logic/update/verifier.go b/internal/logic/update/verifier.go index eb82e0a..01e15d1 100644 --- a/internal/logic/update/verifier.go +++ b/internal/logic/update/verifier.go @@ -28,23 +28,22 @@ func VerifyChecksum(ctx context.Context, filePath, checksumURL, expectedFilename g.Log().Debugf(ctx, "Computed SHA256: %s", actualHash) - // If no checksum URL, skip verification (log warning) + // 缺少校验文件地址时禁止放行:无法确认更新包完整性,宁可中止升级 if checksumURL == "" { - g.Log().Warning(ctx, "No checksum URL available, skipping verification") - return nil + return fmt.Errorf("release 未提供 checksums-sha256.txt,无法校验更新包完整性,已中止更新") } // Download checksums file expectedHash, err := fetchExpectedHash(ctx, checksumURL, expectedFilename) if err != nil { - g.Log().Warningf(ctx, "Failed to fetch checksum file, skipping verification: %v", err) - return nil + // 拉不到校验文件同样不放行,防止跳过校验把破损/篡改的包替换上线 + return fmt.Errorf("获取校验文件失败: %w", err) } manager.setProgress(PhaseVerifying, "正在对比校验值...", 80) if !strings.EqualFold(actualHash, expectedHash) { - return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actualHash) + return fmt.Errorf("SHA256 校验不一致(期望 %s,实际 %s),下载的文件可能已破损或被篡改,已中止更新", expectedHash, actualHash) } manager.setProgress(PhaseVerifying, "文件校验通过", 100) diff --git a/internal/logic/update/verifier_test.go b/internal/logic/update/verifier_test.go new file mode 100644 index 0000000..9d1501f --- /dev/null +++ b/internal/logic/update/verifier_test.go @@ -0,0 +1,126 @@ +package update + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeTempFile 在临时目录写入内容并返回路径 +func writeTempFile(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("写入临时文件失败: %v", err) + } + return path +} + +// serveChecksums 启动一个返回指定内容的测试 HTTP 服务 +func serveChecksums(t *testing.T, status int, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestVerifyChecksum_成功(t *testing.T) { + content := "fake-binary-content" + filePath := writeTempFile(t, "team-api-1.0.0-linux-amd64.tar.gz", content) + + sum := sha256.Sum256([]byte(content)) + hash := hex.EncodeToString(sum[:]) + + srv := serveChecksums(t, http.StatusOK, hash+" team-api-1.0.0-linux-amd64.tar.gz\n") + + if err := VerifyChecksum(context.Background(), filePath, srv.URL, "team-api-1.0.0-linux-amd64.tar.gz"); err != nil { + t.Fatalf("校验应通过,实际失败: %v", err) + } +} + +func TestVerifyChecksum_哈希不一致须报错(t *testing.T) { + filePath := writeTempFile(t, "asset.tar.gz", "actual-content") + + wrongHash := strings.Repeat("0", 64) + srv := serveChecksums(t, http.StatusOK, wrongHash+" asset.tar.gz\n") + + err := VerifyChecksum(context.Background(), filePath, srv.URL, "asset.tar.gz") + if err == nil { + t.Fatal("哈希不一致必须返回错误,不允许更新") + } + if !strings.Contains(err.Error(), "SHA256 校验不一致") { + t.Errorf("错误信息应说明校验不一致,实际: %v", err) + } +} + +func TestVerifyChecksum_缺少校验文件地址须拒绝(t *testing.T) { + filePath := writeTempFile(t, "asset.tar.gz", "content") + + err := VerifyChecksum(context.Background(), filePath, "", "asset.tar.gz") + if err == nil { + t.Fatal("无 checksum URL 时必须返回错误,不允许跳过校验放行") + } + if !strings.Contains(err.Error(), "未提供") { + t.Errorf("错误信息应说明缺少校验文件,实际: %v", err) + } +} + +func TestVerifyChecksum_校验文件拉取失败须拒绝(t *testing.T) { + filePath := writeTempFile(t, "asset.tar.gz", "content") + + // 服务返回 500,模拟校验文件不可用 + srv := serveChecksums(t, http.StatusInternalServerError, "boom") + + err := VerifyChecksum(context.Background(), filePath, srv.URL, "asset.tar.gz") + if err == nil { + t.Fatal("校验文件拉取失败时必须返回错误,不允许跳过校验放行") + } +} + +func TestFetchExpectedHash_解析格式(t *testing.T) { + content := "aaa file-a.tar.gz\nbbb *file-b.tar.gz\n" + srv := serveChecksums(t, http.StatusOK, content) + ctx := context.Background() + + // 标准格式(两个空格分隔) + if got, err := fetchExpectedHash(ctx, srv.URL, "file-a.tar.gz"); err != nil || got != "aaa" { + t.Errorf("双空格格式解析失败: got=%q err=%v", got, err) + } + // 二进制格式(* 前缀) + if got, err := fetchExpectedHash(ctx, srv.URL, "file-b.tar.gz"); err != nil || got != "bbb" { + t.Errorf("* 前缀格式解析失败: got=%q err=%v", got, err) + } + // 传入带路径的文件名应按 basename 匹配 + if got, err := fetchExpectedHash(ctx, srv.URL, "/some/dir/file-a.tar.gz"); err != nil || got != "aaa" { + t.Errorf("basename 匹配失败: got=%q err=%v", got, err) + } + // 文件名不存在应报错 + if _, err := fetchExpectedHash(ctx, srv.URL, "not-exist.tar.gz"); err == nil { + t.Error("校验文件中不存在目标文件时应返回错误") + } +} + +func TestComputeSHA256(t *testing.T) { + content := "hello team-api" + filePath := writeTempFile(t, "bin", content) + + sum := sha256.Sum256([]byte(content)) + want := hex.EncodeToString(sum[:]) + + got, err := computeSHA256(context.Background(), filePath) + if err != nil { + t.Fatalf("计算 SHA256 失败: %v", err) + } + if got != want { + t.Errorf("SHA256 不匹配: got=%s want=%s", got, want) + } +} diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 8b79d79..f541311 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -708,7 +708,10 @@ onUnmounted(() => { -