diff --git a/internal/logic/update/checker.go b/internal/logic/update/checker.go index ef215cf..be0756b 100644 --- a/internal/logic/update/checker.go +++ b/internal/logic/update/checker.go @@ -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 diff --git a/internal/logic/update/replacer.go b/internal/logic/update/replacer.go index 9e9e197..bf5e0eb 100644 --- a/internal/logic/update/replacer.go +++ b/internal/logic/update/replacer.go @@ -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. diff --git a/internal/logic/update/update.go b/internal/logic/update/update.go index 93b850d..66a1ca3 100644 --- a/internal/logic/update/update.go +++ b/internal/logic/update/update.go @@ -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 diff --git a/internal/logic/update/update_integration_test.go b/internal/logic/update/update_integration_test.go new file mode 100644 index 0000000..926a810 --- /dev/null +++ b/internal/logic/update/update_integration_test.go @@ -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) + } +} diff --git a/manifest/config/config.example.yaml b/manifest/config/config.example.yaml index 42923b8..12c1a6d 100644 --- a/manifest/config/config.example.yaml +++ b/manifest/config/config.example.yaml @@ -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: ""