feat(update):支持指定二进制更新地址,便于本地测试更新功能
This commit is contained in:
1 parent
77cb3d0a01
commit
e161401368
5 files changed
+280
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: ""
|
||||
Reference in new issue
Block a user