Files

324 lines
9.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package update
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/qianfree/team-api/internal/consts"
)
// updateDir is the temp directory for update operations(本地集成测试可覆盖为临时目录)
var updateDir = "/tmp/team-api-update"
const (
// rollbackFile stores rollback metadata
rollbackFile = "rollback.json"
// pendingVerificationFile marks a just-updated process
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
var manager *UpdateManager
func init() {
manager = &UpdateManager{}
manager.updating.Store(false)
}
// GetManager returns the singleton UpdateManager
func GetManager() *UpdateManager {
return manager
}
// InitManager initializes the update manager and cleans old backups
func InitManager(ctx context.Context) {
// Ensure update directory exists
_ = os.MkdirAll(updateDir, 0755)
// Clean old backups
cleanOldBackups(ctx)
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) {
pendingPath := filepath.Join(updateDir, pendingVerificationFile)
data, err := os.ReadFile(pendingPath)
if err != nil {
return // no pending verification
}
var info struct {
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)
return
}
g.Log().Infof(ctx, "Detected pending update verification for version %s", info.Version)
// Verify health in background
go func() {
time.Sleep(5 * time.Second)
// 回滚入口展示用的版本号:优先取 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, 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, selfHealthURL())
}
if err == nil && resp != nil {
defer resp.Close()
if resp.StatusCode == 200 {
g.Log().Infof(ctx, "Update to %s verified successfully", info.Version)
_ = os.Remove(pendingPath)
// Update status in Redis
status := &Status{
CurrentVersion: consts.Version,
DeploymentMode: GetDeploymentMode(),
Updating: false,
Progress: &Progress{Phase: PhaseComplete, Message: "更新完成", Percentage: 100, FinishedAt: &verifiedAt},
RollbackAvailable: info.OldBinary != "",
BackupVersion: backupVersion,
}
manager.status.Store(status)
// Clean up .old file(旧版本被 replaceBinary 重命名为 {exe}.old;
// 成功验证后移除该残留。回滚依赖的 .backup.* 副本不受影响)
if info.OldBinary != "" {
_ = os.Remove(info.OldBinary)
}
return
}
}
// Health check failed after retries
g.Log().Errorf(ctx, "Update to %s verification failed - health check not passing", info.Version)
status := &Status{
CurrentVersion: consts.Version,
DeploymentMode: GetDeploymentMode(),
Updating: false,
Progress: &Progress{
Phase: PhaseFailed,
Message: "更新后健康检查失败,建议回滚",
Error: "health check failed after update",
FinishedAt: &verifiedAt,
},
RollbackAvailable: info.OldBinary != "",
BackupVersion: backupVersion,
}
manager.status.Store(status)
}()
}
// BackgroundCheck performs an update check (called by cron)
func BackgroundCheck(ctx context.Context) error {
result, err := CheckForUpdate(ctx, false)
if err != nil {
g.Log().Warningf(ctx, "Background update check failed: %v", err)
return err
}
if result != nil && result.HasUpdate {
g.Log().Infof(ctx, "New version available: %s (current: %s)", result.LatestVersion, result.CurrentVersion)
}
return nil
}
// GetStatus returns the current update status.
// 终态进度(complete/failed)超过 progressExpire 后视为过期,返回时剔除,
// 避免上次升级留下的快照在进程内永久残留(快照本身保留在内存中不影响,
// 下一次 setProgress 会以无进度的状态重建)
func (m *UpdateManager) GetStatus() *Status {
val := m.status.Load()
if val == nil {
return &Status{
CurrentVersion: consts.Version,
DeploymentMode: GetDeploymentMode(),
Updating: false,
}
}
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
func (m *UpdateManager) GetCheckResult() *CheckResult {
val := m.checkResult.Load()
if val == nil {
return nil
}
return val.(*CheckResult)
}
// IsUpdating returns whether an update is in progress
func (m *UpdateManager) IsUpdating() bool {
return m.updating.Load()
}
// setProgress updates the current progress
func (m *UpdateManager) setProgress(phase, message string, percentage int) {
status := m.GetStatus()
status.Updating = true
status.Progress = &Progress{
Phase: phase,
Message: message,
Percentage: percentage,
}
m.status.Store(status)
}
// setProgressError marks the update as failed
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,
FinishedAt: &failedAt,
}
m.status.Store(status)
m.updating.Store(false)
}
// cleanOldBackups removes backup files older than backupMaxAge
func cleanOldBackups(ctx context.Context) {
exe, err := os.Executable()
if err != nil {
return
}
dir := filepath.Dir(exe)
entries, err := os.ReadDir(dir)
if err != nil {
return
}
base := filepath.Base(exe)
now := time.Now()
for _, entry := range entries {
name := entry.Name()
// Match pattern: {binary}.backup.{timestamp}
if len(name) > len(base) && name[:len(base)] == base {
suffix := name[len(base):]
if len(suffix) > 8 && suffix[:8] == ".backup." {
info, err := entry.Info()
if err != nil {
continue
}
if now.Sub(info.ModTime()) > backupMaxAge {
_ = os.Remove(filepath.Join(dir, name))
g.Log().Debugf(ctx, "Cleaned old backup: %s", name)
}
}
}
}
// 清理残留的 {binary}.old:正常链路在自检成功后已移除,
// 这里兜底处理进程异常终止(自检未执行)留下的旧版本副本
_ = os.Remove(filepath.Join(dir, base+".old"))
}
// GetDeploymentMode returns the current deployment mode
func GetDeploymentMode() string {
if IsDocker() {
return DeploymentDocker
}
return DeploymentBinary
}
// getPlatformAssetName constructs the expected asset name for the current platform
func getPlatformAssetName(version string) string {
osName := runtime.GOOS
arch := runtime.GOARCH
ext := "tar.gz"
if osName == "windows" {
ext = "zip"
}
// Strip 'v' prefix if present
v := version
if len(v) > 0 && v[0] == 'v' {
v = v[1:]
}
return fmt.Sprintf("team-api-%s-%s-%s.%s", v, osName, arch, ext)
}
// normalizeAssetName 将资产名归一化到"无 v 前缀"的规范形式,用于兼容历史上
// 以 "team-api-v{version}-..." 命名的旧资产(release.yml 早期版本未去掉 v)。
// 例:team-api-v0.2.0-linux-amd64.tar.gz → team-api-0.2.0-linux-amd64.tar.gz
func normalizeAssetName(name string) string {
if strings.HasPrefix(name, "team-api-v") {
return "team-api-" + strings.TrimPrefix(name, "team-api-v")
}
return name
}