fix(update):修复在线更新多个问题
This commit is contained in:
1 parent
6aab6b61a1
commit
c286d4d2df
10 files changed
+705
-28
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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("源文件不存在时应返回错误")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -708,7 +708,10 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<!-- Update available -->
|
||||
<template v-if="canUpdate && !isUpdating && !isFailed && !isComplete && !isDocker">
|
||||
<!-- 注意不能被 isFailed/isComplete 挡住:后端的 complete/failed 进度快照在进程内
|
||||
不会过期(上次升级留下的状态会一直挂着),否则新版本出现后更新入口被吞掉、
|
||||
失败后也无法重试。更新进行中(isUpdating)才是真正的互斥条件 -->
|
||||
<template v-if="canUpdate && !isUpdating && !isDocker">
|
||||
<a-divider style="margin: 16px 0;" />
|
||||
<div style="margin-bottom: 12px;">
|
||||
<a-typography-text bold>更新说明</a-typography-text>
|
||||
@@ -763,7 +766,9 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<!-- Complete with rollback option -->
|
||||
<template v-if="isComplete && rollbackAvailable">
|
||||
<!-- 有新版本可更新时隐藏:这个 complete 是历史升级留下的常驻状态,
|
||||
不应与新版本入口同时出现 -->
|
||||
<template v-if="isComplete && rollbackAvailable && !canUpdate">
|
||||
<a-divider style="margin: 16px 0;" />
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<a-typography-text type="secondary" style="font-size: 12px;">如遇问题可回滚到 v{{ backupVersion }}</a-typography-text>
|
||||
|
||||
Reference in new issue
Block a user