feat(update):支持指定二进制更新地址,便于本地测试更新功能

This commit is contained in:
martin committed 2026-08-15 16:34:23 +08:00
1 parent 77cb3d0a01
commit e161401368
5 files changed
+280 -20

No files matched your search

+28 -4
View File
@@ -121,12 +121,21 @@ func CheckForUpdate(ctx context.Context, force bool) (*CheckResult, error) {
return result, nil
}
// updateAPIBase 返回更新检查的 API 基地址。默认 GitHub 官方 API;
// 可通过配置 update.api_base 覆盖(自建发布服务器 / 本地测试 / 国内镜像)。
func updateAPIBase(ctx context.Context) string {
if base := g.Cfg().MustGet(ctx, "update.api_base").String(); base != "" {
return strings.TrimSuffix(base, "/")
}
return githubAPIBase
}
// fetchLatestRelease calls the GitHub Releases API.
// conditional 为 true 时携带 If-None-Match ETag 做条件请求(省配额);
// 强制检测(force)必须传 false:GitHub /releases/latest 有 CDN 缓存,刚发布新版本后
// 条件请求可能返回过期的 304 Not Modified,导致强制检测仍拿不到最新发行版。
func fetchLatestRelease(ctx context.Context, conditional bool) (*GitHubRelease, string, error) {
url := githubAPIBase + "/releases/latest"
url := updateAPIBase(ctx) + "/releases/latest"
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
@@ -176,8 +185,17 @@ func fetchLatestRelease(ctx context.Context, conditional bool) (*GitHubRelease,
return &release, etag, nil
}
// getCheckCache reads cached check result from Redis
func getCheckCache(ctx context.Context) *CheckResult {
// getCheckCache reads cached check result from Redis.
// 缓存是纯优化(TTL 24h),Redis 未配置/不可用时应降级为无缓存、走实时检查,
// 绝不能因 g.Redis() 客户端创建失败而 panic(见 goframe-conventions.md 已修复记录)。
func getCheckCache(ctx context.Context) (out *CheckResult) {
defer func() {
if r := recover(); r != nil {
g.Log().Warningf(ctx, "update check cache read skipped: %v", r)
out = nil
}
}()
val, err := g.Redis().Do(ctx, "GET", redisCacheKey)
if err != nil || val.IsNil() || val.IsEmpty() {
return nil
@@ -190,8 +208,14 @@ func getCheckCache(ctx context.Context) *CheckResult {
return &result
}
// setCheckCache stores check result in Redis
// setCheckCache stores check result in Redis. 与 getCheckCache 同理,失败仅降级不 panic。
func setCheckCache(ctx context.Context, result *CheckResult) {
defer func() {
if r := recover(); r != nil {
g.Log().Warningf(ctx, "update check cache write skipped: %v", r)
}
}()
data, err := json.Marshal(result)
if err != nil {
return
+26 -14
View File
@@ -19,6 +19,10 @@ import (
"github.com/qianfree/team-api/internal/consts"
)
// executablePath 获取当前二进制路径。默认 os.Executable;本地集成测试可覆盖为
// 指向临时目录的假二进制,避免真实替换测试进程自身(见 update_integration_test.go)。
var executablePath = os.Executable
// ExecuteUpdate runs the full update process in a goroutine
func ExecuteUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL string, assetSize int64) error {
// Check if already updating
@@ -74,13 +78,27 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL
}
}()
if _, err := stageUpdate(ctx, targetVersion, downloadURL, checksumURL, assetSize); err != nil {
return // stageUpdate 已设置失败状态并记录日志
}
// 触发重启:SIGTERM 优雅关闭 → cmd.go defer 链排空任务池/flush 日志
// → MaybeExecRestart 用 syscall.Exec 原地换壳为新版本(同 PID,不依赖进程管理器)
manager.setProgress(PhaseRestarting, "正在重启服务...", 90)
g.Log().Info(ctx, "Update complete, restarting...")
gracefulExit(ctx, "update complete")
}
// stageUpdate 执行下载→校验→备份→替换→写标记的完整流水线,返回替换后的新二进制路径。
// 与 performUpdate 分离,便于本地集成测试直接驱动生产流水线而不触发进程重启(exec)。
func stageUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL string, assetSize int64) (string, error) {
// Step 1: Download
assetName := getPlatformAssetName(targetVersion)
dlResult, err := DownloadFile(ctx, downloadURL, assetName, assetSize)
if err != nil {
manager.setProgressError("下载更新文件失败", err.Error())
g.Log().Errorf(ctx, "Download failed: %v", err)
return
return "", err
}
// Step 2: Verify(校验失败一律中止,不放行未通过完整性校验的更新包)
@@ -88,17 +106,17 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL
manager.setProgressError("文件校验失败,更新包可能已破损", err.Error())
g.Log().Errorf(ctx, "Checksum verification failed: %v", err)
_ = os.Remove(dlResult.FilePath)
return
return "", err
}
// Step 3: 定位当前二进制路径
// 新二进制必须提取到与当前二进制相同的目录(同一文件系统),
// 否则后续 os.Rename 会因跨设备(EXDEV)失败,如 /tmp 与部署目录不在同一分区
currentExe, err := os.Executable()
currentExe, err := executablePath()
if err != nil {
manager.setProgressError("获取当前程序路径失败", err.Error())
_ = os.Remove(dlResult.FilePath)
return
return "", err
}
// Resolve symlinks
currentExe, err = filepath.EvalSymlinks(currentExe)
@@ -113,7 +131,7 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL
manager.setProgressError("提取文件失败", err.Error())
g.Log().Errorf(ctx, "Extract failed: %v", err)
_ = os.Remove(dlResult.FilePath)
return
return "", err
}
_ = os.Remove(dlResult.FilePath) // clean up tarball
@@ -124,7 +142,7 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL
if err := copyFile(currentExe, backupPath); err != nil {
manager.setProgressError("备份当前版本失败", err.Error())
_ = os.Remove(newBinaryPath)
return
return "", err
}
g.Log().Infof(ctx, "Backed up current binary to %s", backupPath)
@@ -145,7 +163,7 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL
manager.setProgressError("替换程序文件失败", err.Error())
g.Log().Errorf(ctx, "Replace failed: %v", err)
_ = os.Remove(newBinaryPath)
return
return "", err
}
// Step 7: Write pending verification marker
@@ -167,13 +185,7 @@ func performUpdate(ctx context.Context, targetVersion, downloadURL, checksumURL
// 同 PID 继续运行,不依赖外部进程管理器拉起(见 gracefulExit 注释)
manager.SetRestartBinary(currentExe)
// Step 8: Restart
manager.setProgress(PhaseRestarting, "正在重启服务...", 90)
g.Log().Info(ctx, "Update complete, restarting...")
// 走 SIGTERM 优雅关闭链路(见 gracefulExit 注释),让 cmd.go 的 defer 链
// 排空任务池并 flush 用量/审计日志后自然退出,由进程管理器拉起新版本
gracefulExit(ctx, "update complete")
return currentExe, nil
}
// extractBinary extracts the team-api binary from a tar.gz archive to outputPath.
+3 -2
View File
@@ -17,9 +17,10 @@ import (
"github.com/qianfree/team-api/internal/consts"
)
// updateDir is the temp directory for update operations(本地集成测试可覆盖为临时目录)
var updateDir = "/tmp/team-api-update"
const (
// updateDir is the temp directory for update operations
updateDir = "/tmp/team-api-update"
// rollbackFile stores rollback metadata
rollbackFile = "rollback.json"
// pendingVerificationFile marks a just-updated process
@@ -0,0 +1,216 @@
package update
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/gogf/gf/v2/os/gcfg"
"github.com/gogf/gf/v2/os/gctx"
)
// buildReleaseTarGz 构造符合 getPlatformAssetName 命名的 release 资产 tar.gz,
// 内部结构 {assetDir}/team-api(与 extractBinary 的查找约定一致)。
func buildReleaseTarGz(t *testing.T, dir, version string, content []byte) string {
t.Helper()
assetDir := fmt.Sprintf("team-api-%s-%s-%s", version, runtime.GOOS, runtime.GOARCH)
path := filepath.Join(dir, assetDir+".tar.gz")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
_ = tw.WriteHeader(&tar.Header{Name: assetDir + "/", Typeflag: tar.TypeDir, Mode: 0755})
if err := tw.WriteHeader(&tar.Header{
Name: assetDir + "/team-api", Typeflag: tar.TypeReg,
Size: int64(len(content)), Mode: 0755,
}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(content); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return path
}
func sha256File(t *testing.T, path string) string {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
t.Fatal(err)
}
return hex.EncodeToString(h.Sum(nil))
}
func fileSize(t *testing.T, path string) int64 {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
return info.Size()
}
// startMockReleaseServer 启动一个模拟 GitHub release 的本地 HTTP 服务:
// /releases/latest 返回版本信息,/assets/ 下发 tar.gz 与 checksums。
// 返回服务地址与工作目录(含资产文件)。
func startMockReleaseServer(t *testing.T, version string) (*httptest.Server, string) {
t.Helper()
workDir := t.TempDir()
tarPath := buildReleaseTarGz(t, workDir, version, []byte("FAKE-NEW-BINARY-"+version))
assetName := filepath.Base(tarPath)
checksums := fmt.Sprintf("%s %s\n", sha256File(t, tarPath), assetName)
var tsURL string
mux := http.NewServeMux()
mux.HandleFunc("/releases/latest", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"tag_name":"v%s","name":"v%s",`+
`"assets":[{"name":%q,"browser_download_url":%q,"size":%d},`+
`{"name":"checksums-sha256.txt","browser_download_url":%q}]}`,
version, version,
assetName, tsURL+"/assets/"+assetName, fileSize(t, tarPath),
tsURL+"/assets/checksums-sha256.txt")
})
mux.HandleFunc("/assets/", func(w http.ResponseWriter, r *http.Request) {
switch strings.TrimPrefix(r.URL.Path, "/assets/") {
case assetName:
http.ServeFile(w, r, tarPath)
case "checksums-sha256.txt":
_, _ = io.WriteString(w, checksums)
default:
http.NotFound(w, r)
}
})
ts := httptest.NewServer(mux)
tsURL = ts.URL
t.Cleanup(ts.Close)
return ts, workDir
}
// TestUpdatePipeline_Local 在本地用模拟 release 服务器驱动生产流水线
// (CheckForUpdate → stageUpdate:下载→校验→备份→替换→写标记),
// 验证整个更新链路的磁盘产物,无需 GitHub / PostgreSQL / Redis。
func TestUpdatePipeline_Local(t *testing.T) {
ctx := gctx.New()
version := "0.9.0" // 模拟的"新版本",必须高于当前版本才判定有更新
ts, _ := startMockReleaseServer(t, version)
// 模拟"当前二进制":stageUpdate 会把它备份/替换(executablePath seam 指向这里,
// 避免真实操作测试进程自身的可执行文件)
work := t.TempDir()
currentBin := filepath.Join(work, "team-api")
if err := os.WriteFile(currentBin, []byte("FAKE-CURRENT-0.2.0"), 0755); err != nil {
t.Fatal(err)
}
origExec := executablePath
executablePath = func() (string, error) { return currentBin, nil }
t.Cleanup(func() { executablePath = origExec })
// 更新目录指向临时目录,避免污染全局 /tmp/team-api-update
origUpdateDir := updateDir
updateDir = t.TempDir()
t.Cleanup(func() { updateDir = origUpdateDir })
// 更新源指向本地 mock。Redis 缓存未配置时已被 getCheckCache/setCheckCache 防御性
// recover 降级,无需在测试里配置 Redis。
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf("update:\n api_base: %s\n", ts.URL))
if err != nil {
t.Fatal(err)
}
gcfg.Instance().SetAdapter(adapter)
// 1) 版本检查应命中 mock:判定有更新,下载地址指向本地
res, err := CheckForUpdate(ctx, true)
if err != nil {
t.Fatalf("CheckForUpdate failed: %v", err)
}
if !res.HasUpdate {
t.Fatalf("expected HasUpdate=true, got false (current=%q latest=%q)", res.CurrentVersion, res.LatestVersion)
}
if !strings.HasPrefix(res.DownloadURL, ts.URL) {
t.Fatalf("DownloadURL %q does not point at local mock %s", res.DownloadURL, ts.URL)
}
if res.LatestVersion != version {
t.Fatalf("LatestVersion=%q, want %q", res.LatestVersion, version)
}
// 2) 驱动真实生产流水线(stageUpdate,不触发进程重启)。
// stageUpdate 内部会 EvalSymlinks(如 macOS 上 /var → /private/var),
// 返回值是解析后的绝对路径,断言前同样解析。
resolvedBin, err := filepath.EvalSymlinks(currentBin)
if err != nil {
t.Fatal(err)
}
newExe, err := stageUpdate(ctx, version, res.DownloadURL, res.ChecksumURL, res.AssetSize)
if err != nil {
t.Fatalf("stageUpdate failed: %v", err)
}
if newExe != resolvedBin {
t.Fatalf("stageUpdate returned %q, want %q", newExe, resolvedBin)
}
// 3) 校验磁盘产物
got, err := os.ReadFile(currentBin)
if err != nil {
t.Fatal(err)
}
if string(got) != "FAKE-NEW-BINARY-"+version {
t.Fatalf("current binary not replaced, content=%q", string(got))
}
if _, err := os.Stat(currentBin + ".old"); err != nil {
t.Fatalf("expected .old file: %v", err)
}
entries, _ := os.ReadDir(work)
hasBackup := false
for _, e := range entries {
if strings.HasPrefix(e.Name(), "team-api.backup.") {
hasBackup = true
}
}
if !hasBackup {
t.Fatal("expected a team-api.backup.* file")
}
if _, err := os.Stat(filepath.Join(updateDir, rollbackFile)); err != nil {
t.Fatalf("rollback.json not written: %v", err)
}
if _, err := os.Stat(filepath.Join(updateDir, pendingVerificationFile)); err != nil {
t.Fatalf("pending_verification not written: %v", err)
}
// 4) 待重启的新版本路径已记录(供退出链 exec 换壳)
if got := manager.ConsumeRestartBinary(); got != resolvedBin {
t.Fatalf("restart binary = %q, want %q", got, resolvedBin)
}
}
+7
View File
@@ -85,3 +85,10 @@ demo:
# Channel proxy (for accessing Google/Anthropic etc. from China)
channel_proxy_url: ""
# System update source override
# Default: GitHub official API (https://api.github.com/repos/qianfree/team-api).
# Override to point at a self-hosted release server / local mock release (see
# scripts/mock-release.sh) or a China mirror. Leave empty to use GitHub.
update:
api_base: ""