mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-10 17:26:38 +08:00
feat: add support for release channels in version upgrade and node agent updates
- Introduced ReleaseChannel type to manage stable and preview releases. - Updated DashboardTopbar to handle version upgrades based on selected release channel. - Enhanced node detail page to allow manual checks for agent updates on stable and preview channels. - Modified API endpoints to support fetching and upgrading based on release channels. - Updated UI components to reflect changes in version checking and upgrade processes. - Added tests for new functionality related to preview releases and agent updates.
This commit is contained in:
@@ -50,6 +50,8 @@ type AgentSettings struct {
|
||||
AutoUpdate bool `json:"auto_update"`
|
||||
UpdateRepo string `json:"update_repo"`
|
||||
UpdateNow bool `json:"update_now"`
|
||||
UpdateChannel string `json:"update_channel"`
|
||||
UpdateTag string `json:"update_tag"`
|
||||
}
|
||||
|
||||
type HeartbeatResponse struct {
|
||||
@@ -65,6 +67,8 @@ type NodeView struct {
|
||||
AgentToken string `json:"agent_token"`
|
||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||
UpdateRequested bool `json:"update_requested"`
|
||||
UpdateChannel string `json:"update_channel"`
|
||||
UpdateTag string `json:"update_tag"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
NginxVersion string `json:"nginx_version"`
|
||||
Status string `json:"status"`
|
||||
@@ -90,9 +94,13 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon
|
||||
return nil, err
|
||||
}
|
||||
updateNow := node.UpdateRequested
|
||||
updateChannel := normalizeReleaseChannel(node.UpdateChannel)
|
||||
updateTag := strings.TrimSpace(node.UpdateTag)
|
||||
applyNodeRuntime(node, payload, true)
|
||||
node.UpdateRequested = false
|
||||
if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error", "update_requested").Updates(node).Error; err != nil {
|
||||
node.UpdateChannel = ReleaseChannelStable.String()
|
||||
node.UpdateTag = ""
|
||||
if err := model.DB.Model(node).Select("ip", "agent_version", "nginx_version", "status", "current_version", "last_seen_at", "last_error", "update_requested", "update_channel", "update_tag").Updates(node).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &HeartbeatResponse{
|
||||
@@ -103,6 +111,8 @@ func HeartbeatNode(node *model.Node, payload AgentNodePayload) (*HeartbeatRespon
|
||||
AutoUpdate: node.AutoUpdateEnabled,
|
||||
UpdateRepo: common.AgentUpdateRepo,
|
||||
UpdateNow: updateNow,
|
||||
UpdateChannel: updateChannel.String(),
|
||||
UpdateTag: updateTag,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"atsflare/common"
|
||||
"atsflare/model"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -15,6 +16,25 @@ type NodeInput struct {
|
||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||
}
|
||||
|
||||
type NodeAgentUpdateInput struct {
|
||||
Channel string `json:"channel"`
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
|
||||
type NodeAgentReleaseInfo struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
HasUpdate bool `json:"has_update"`
|
||||
Channel string `json:"channel"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
UpdateRequested bool `json:"update_requested"`
|
||||
RequestedChannel string `json:"requested_channel"`
|
||||
RequestedTag string `json:"requested_tag"`
|
||||
}
|
||||
|
||||
type NodeBootstrapView struct {
|
||||
DiscoveryToken string `json:"discovery_token"`
|
||||
}
|
||||
@@ -84,16 +104,44 @@ func DeleteNode(id uint) error {
|
||||
return node.Delete()
|
||||
}
|
||||
|
||||
func RequestNodeAgentUpdate(id uint) (*NodeView, error) {
|
||||
func GetNodeAgentRelease(ctx context.Context, id uint, channel string) (*NodeAgentReleaseInfo, error) {
|
||||
node, err := model.GetNodeByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node.UpdateRequested = true
|
||||
if err = model.DB.Model(node).Update("update_requested", true).Error; err != nil {
|
||||
release, err := fetchLatestGitHubRelease(ctx, common.AgentUpdateRepo, normalizeReleaseChannel(channel))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.SysLog("agent manual update requested: node_id=" + node.NodeID + " name=" + node.Name)
|
||||
return buildNodeAgentReleaseView(node, release, normalizeReleaseChannel(channel)), nil
|
||||
}
|
||||
|
||||
func RequestNodeAgentUpdate(id uint, input NodeAgentUpdateInput) (*NodeView, error) {
|
||||
node, err := model.GetNodeByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channel := normalizeReleaseChannel(input.Channel)
|
||||
tagName := strings.TrimSpace(input.TagName)
|
||||
if tagName != "" {
|
||||
release, releaseErr := fetchGitHubReleaseByTag(context.Background(), common.AgentUpdateRepo, tagName)
|
||||
if releaseErr != nil {
|
||||
return nil, releaseErr
|
||||
}
|
||||
if channel == ReleaseChannelPreview && !release.Prerelease {
|
||||
return nil, errors.New("指定版本不是 preview 发布")
|
||||
}
|
||||
if channel == ReleaseChannelStable && release.Prerelease {
|
||||
return nil, errors.New("正式版更新不能选择 preview 发布")
|
||||
}
|
||||
}
|
||||
node.UpdateRequested = true
|
||||
node.UpdateChannel = channel.String()
|
||||
node.UpdateTag = tagName
|
||||
if err = model.DB.Model(node).Select("update_requested", "update_channel", "update_tag").Updates(node).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.SysLog("agent manual update requested: node_id=" + node.NodeID + " name=" + node.Name + " channel=" + channel.String() + " tag=" + tagName)
|
||||
return buildNodeView(node), nil
|
||||
}
|
||||
|
||||
@@ -170,6 +218,8 @@ func buildNodeView(node *model.Node) *NodeView {
|
||||
Name: node.Name,
|
||||
IP: node.IP,
|
||||
AgentToken: node.AgentToken,
|
||||
UpdateChannel: strings.TrimSpace(node.UpdateChannel),
|
||||
UpdateTag: strings.TrimSpace(node.UpdateTag),
|
||||
AgentVersion: node.AgentVersion,
|
||||
NginxVersion: node.NginxVersion,
|
||||
Status: status,
|
||||
@@ -181,6 +231,30 @@ func buildNodeView(node *model.Node) *NodeView {
|
||||
AutoUpdateEnabled: node.AutoUpdateEnabled,
|
||||
UpdateRequested: node.UpdateRequested,
|
||||
}
|
||||
if view.UpdateChannel == "" {
|
||||
view.UpdateChannel = ReleaseChannelStable.String()
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func buildNodeAgentReleaseView(node *model.Node, release *githubReleaseResponse, channel ReleaseChannel) *NodeAgentReleaseInfo {
|
||||
currentVersion := strings.TrimSpace(node.AgentVersion)
|
||||
view := &NodeAgentReleaseInfo{
|
||||
CurrentVersion: currentVersion,
|
||||
Channel: channel.String(),
|
||||
UpdateRequested: node.UpdateRequested,
|
||||
RequestedChannel: normalizeReleaseChannel(node.UpdateChannel).String(),
|
||||
RequestedTag: strings.TrimSpace(node.UpdateTag),
|
||||
}
|
||||
if release == nil {
|
||||
return view
|
||||
}
|
||||
view.TagName = release.TagName
|
||||
view.Body = release.Body
|
||||
view.HTMLURL = release.HTMLURL
|
||||
view.PublishedAt = release.PublishedAt
|
||||
view.Prerelease = release.Prerelease
|
||||
view.HasUpdate = isVersionNewer(currentVersion, release.TagName)
|
||||
return view
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"atsflare/common"
|
||||
"atsflare/model"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestRequestNodeAgentPreviewUpdate(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
node, err := CreateNode(NodeInput{Name: "preview-edge-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create node: %v", err)
|
||||
}
|
||||
|
||||
originalClient := UpdateHTTPClientForTest()
|
||||
SetUpdateHTTPClientForTest(&http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.String() != "https://api.github.com/repos/"+common.AgentUpdateRepo+"/releases/tags/v0.5.0-rc.1" {
|
||||
t.Fatalf("unexpected request url: %s", req.URL.String())
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"tag_name":"v0.5.0-rc.1","prerelease":true}`)),
|
||||
}, nil
|
||||
}),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
SetUpdateHTTPClientForTest(originalClient)
|
||||
})
|
||||
|
||||
updated, err := RequestNodeAgentUpdate(node.ID, NodeAgentUpdateInput{
|
||||
Channel: "preview",
|
||||
TagName: "v0.5.0-rc.1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected preview update request to succeed: %v", err)
|
||||
}
|
||||
if !updated.UpdateRequested {
|
||||
t.Fatal("expected update_requested to be true")
|
||||
}
|
||||
if updated.UpdateChannel != "preview" {
|
||||
t.Fatalf("unexpected update channel: %s", updated.UpdateChannel)
|
||||
}
|
||||
if updated.UpdateTag != "v0.5.0-rc.1" {
|
||||
t.Fatalf("unexpected update tag: %s", updated.UpdateTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatNodeReturnsPreviewUpdateSettings(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
node := &model.Node{
|
||||
NodeID: "node-preview-1",
|
||||
Name: "preview-edge-1",
|
||||
IP: "10.0.0.8",
|
||||
AgentToken: "agent-token",
|
||||
AgentVersion: "v0.4.0",
|
||||
NginxVersion: "1.27.1.2",
|
||||
Status: NodeStatusOnline,
|
||||
UpdateRequested: true,
|
||||
UpdateChannel: "preview",
|
||||
UpdateTag: "v0.5.0-rc.1",
|
||||
AutoUpdateEnabled: false,
|
||||
}
|
||||
if err := node.Insert(); err != nil {
|
||||
t.Fatalf("failed to seed node: %v", err)
|
||||
}
|
||||
|
||||
resp, err := HeartbeatNode(node, AgentNodePayload{
|
||||
NodeID: node.NodeID,
|
||||
Name: node.Name,
|
||||
IP: node.IP,
|
||||
AgentVersion: node.AgentVersion,
|
||||
NginxVersion: node.NginxVersion,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected heartbeat to succeed: %v", err)
|
||||
}
|
||||
if resp.AgentSettings == nil {
|
||||
t.Fatal("expected agent settings in heartbeat response")
|
||||
}
|
||||
if !resp.AgentSettings.UpdateNow {
|
||||
t.Fatal("expected update_now to be true")
|
||||
}
|
||||
if resp.AgentSettings.UpdateChannel != "preview" {
|
||||
t.Fatalf("unexpected update channel: %s", resp.AgentSettings.UpdateChannel)
|
||||
}
|
||||
if resp.AgentSettings.UpdateTag != "v0.5.0-rc.1" {
|
||||
t.Fatalf("unexpected update tag: %s", resp.AgentSettings.UpdateTag)
|
||||
}
|
||||
|
||||
storedNode, err := model.GetNodeByID(node.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload node: %v", err)
|
||||
}
|
||||
if storedNode.UpdateRequested {
|
||||
t.Fatal("expected update_requested to be reset after heartbeat")
|
||||
}
|
||||
if storedNode.UpdateChannel != "stable" {
|
||||
t.Fatalf("expected update channel to reset to stable, got %s", storedNode.UpdateChannel)
|
||||
}
|
||||
if storedNode.UpdateTag != "" {
|
||||
t.Fatalf("expected update tag to be cleared, got %s", storedNode.UpdateTag)
|
||||
}
|
||||
}
|
||||
+249
-45
@@ -20,7 +20,17 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const latestReleaseURL = "https://api.github.com/repos/Rain-kl/ATSFlare/releases/latest"
|
||||
const (
|
||||
serverReleaseRepo = "Rain-kl/ATSFlare"
|
||||
githubReleasesAPIBase = "https://api.github.com/repos/%s/releases"
|
||||
)
|
||||
|
||||
type ReleaseChannel string
|
||||
|
||||
const (
|
||||
ReleaseChannelStable ReleaseChannel = "stable"
|
||||
ReleaseChannelPreview ReleaseChannel = "preview"
|
||||
)
|
||||
|
||||
var updateHTTPClient = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -45,6 +55,8 @@ type LatestServerRelease struct {
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Channel string `json:"channel"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
HasUpdate bool `json:"has_update"`
|
||||
UpgradeSupported bool `json:"upgrade_supported"`
|
||||
@@ -56,6 +68,8 @@ type githubReleaseResponse struct {
|
||||
Body string `json:"body"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
Assets []githubAsset `json:"assets"`
|
||||
}
|
||||
|
||||
@@ -92,22 +106,24 @@ type manualServerBinaryCandidate struct {
|
||||
UploadedAt time.Time
|
||||
}
|
||||
|
||||
func GetLatestServerRelease(ctx context.Context) (*LatestServerRelease, error) {
|
||||
release, err := fetchLatestRelease(ctx)
|
||||
func GetLatestServerRelease(ctx context.Context, channel string) (*LatestServerRelease, error) {
|
||||
normalizedChannel := normalizeReleaseChannel(channel)
|
||||
release, err := fetchLatestRelease(ctx, normalizedChannel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildLatestServerReleaseView(release), nil
|
||||
return buildLatestServerReleaseView(release, normalizedChannel), nil
|
||||
}
|
||||
|
||||
func ScheduleServerUpgrade() (*LatestServerRelease, error) {
|
||||
func ScheduleServerUpgrade(channel string) (*LatestServerRelease, error) {
|
||||
normalizedChannel := normalizeReleaseChannel(channel)
|
||||
serverUpgradeState.Lock()
|
||||
if serverUpgradeState.inProgress {
|
||||
serverUpgradeState.Unlock()
|
||||
return nil, fmt.Errorf("服务升级已在执行中,请稍后再试")
|
||||
}
|
||||
|
||||
prepared, err := prepareServerUpgrade(context.Background())
|
||||
prepared, err := prepareServerUpgrade(context.Background(), normalizedChannel)
|
||||
if err != nil {
|
||||
serverUpgradeState.Unlock()
|
||||
return nil, err
|
||||
@@ -248,13 +264,25 @@ func ConfirmManualServerUpgrade(uploadToken string) (*UploadedServerBinary, erro
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil)
|
||||
func fetchLatestRelease(ctx context.Context, channel ReleaseChannel) (*githubReleaseResponse, error) {
|
||||
return fetchLatestGitHubRelease(ctx, serverReleaseRepo, channel)
|
||||
}
|
||||
|
||||
func fetchLatestGitHubRelease(ctx context.Context, repo string, channel ReleaseChannel) (*githubReleaseResponse, error) {
|
||||
switch normalizeReleaseChannel(string(channel)) {
|
||||
case ReleaseChannelPreview:
|
||||
return fetchLatestPreviewGitHubRelease(ctx, repo)
|
||||
default:
|
||||
return fetchLatestStableGitHubRelease(ctx, repo)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchLatestStableGitHubRelease(ctx context.Context, repo string) (*githubReleaseResponse, error) {
|
||||
url := fmt.Sprintf(githubReleasesAPIBase+"/latest", strings.TrimSpace(repo))
|
||||
req, err := newGitHubReleaseRequest(ctx, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建更新请求失败")
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "ATSFlare-Server")
|
||||
|
||||
resp, err := updateHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -266,14 +294,86 @@ func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) {
|
||||
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
|
||||
}
|
||||
|
||||
return decodeGitHubRelease(resp.Body)
|
||||
}
|
||||
|
||||
func fetchLatestPreviewGitHubRelease(ctx context.Context, repo string) (*githubReleaseResponse, error) {
|
||||
url := fmt.Sprintf(githubReleasesAPIBase+"?per_page=20", strings.TrimSpace(repo))
|
||||
req, err := newGitHubReleaseRequest(ctx, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建更新请求失败")
|
||||
}
|
||||
|
||||
resp, err := updateHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取 preview 版本失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
|
||||
}
|
||||
|
||||
var releases []githubReleaseResponse
|
||||
if err = json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||
return nil, fmt.Errorf("解析 preview 版本信息失败")
|
||||
}
|
||||
for _, release := range releases {
|
||||
if release.Draft || !release.Prerelease {
|
||||
continue
|
||||
}
|
||||
releaseCopy := release
|
||||
return &releaseCopy, nil
|
||||
}
|
||||
return nil, fmt.Errorf("当前没有可用的 preview 发布")
|
||||
}
|
||||
|
||||
func fetchGitHubReleaseByTag(ctx context.Context, repo string, tag string) (*githubReleaseResponse, error) {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
return nil, fmt.Errorf("缺少发布版本号")
|
||||
}
|
||||
url := fmt.Sprintf(githubReleasesAPIBase+"/tags/%s", strings.TrimSpace(repo), tag)
|
||||
req, err := newGitHubReleaseRequest(ctx, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建更新请求失败")
|
||||
}
|
||||
|
||||
resp, err := updateHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取指定版本失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("未找到指定版本: %s", tag)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitHub 返回异常状态: %s", resp.Status)
|
||||
}
|
||||
|
||||
return decodeGitHubRelease(resp.Body)
|
||||
}
|
||||
|
||||
func newGitHubReleaseRequest(ctx context.Context, url string) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "ATSFlare-Server")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func decodeGitHubRelease(reader io.Reader) (*githubReleaseResponse, error) {
|
||||
var release githubReleaseResponse
|
||||
if err = json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
if err := json.NewDecoder(reader).Decode(&release); err != nil {
|
||||
return nil, fmt.Errorf("解析最新版本信息失败")
|
||||
}
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerRelease {
|
||||
func buildLatestServerReleaseView(release *githubReleaseResponse, channel ReleaseChannel) *LatestServerRelease {
|
||||
currentVersion := strings.TrimSpace(common.Version)
|
||||
isDevBuild := currentVersion == "" || strings.EqualFold(currentVersion, "dev")
|
||||
hasUpdate := false
|
||||
@@ -286,6 +386,7 @@ func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerR
|
||||
serverUpgradeState.Unlock()
|
||||
|
||||
view := &LatestServerRelease{
|
||||
Channel: channel.String(),
|
||||
CurrentVersion: currentVersion,
|
||||
HasUpdate: hasUpdate,
|
||||
UpgradeSupported: !isDevBuild && runtime.GOOS != "windows",
|
||||
@@ -296,17 +397,18 @@ func buildLatestServerReleaseView(release *githubReleaseResponse) *LatestServerR
|
||||
view.Body = release.Body
|
||||
view.HTMLURL = release.HTMLURL
|
||||
view.PublishedAt = release.PublishedAt
|
||||
view.Prerelease = release.Prerelease
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func prepareServerUpgrade(ctx context.Context) (*preparedServerUpgrade, error) {
|
||||
release, err := fetchLatestRelease(ctx)
|
||||
func prepareServerUpgrade(ctx context.Context, channel ReleaseChannel) (*preparedServerUpgrade, error) {
|
||||
release, err := fetchLatestRelease(ctx, channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
view := buildLatestServerReleaseView(release)
|
||||
view := buildLatestServerReleaseView(release, channel)
|
||||
if !view.HasUpdate {
|
||||
return nil, fmt.Errorf("当前已是最新版本")
|
||||
}
|
||||
@@ -411,40 +513,54 @@ func serverAssetName(goos string, goarch string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func isVersionNewer(current string, latest string) bool {
|
||||
currentParts := parseVersionParts(current)
|
||||
latestParts := parseVersionParts(latest)
|
||||
maxLen := len(currentParts)
|
||||
if len(latestParts) > maxLen {
|
||||
maxLen = len(latestParts)
|
||||
func normalizeReleaseChannel(channel string) ReleaseChannel {
|
||||
switch strings.ToLower(strings.TrimSpace(channel)) {
|
||||
case string(ReleaseChannelPreview):
|
||||
return ReleaseChannelPreview
|
||||
default:
|
||||
return ReleaseChannelStable
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
currentPart := 0
|
||||
latestPart := 0
|
||||
if i < len(currentParts) {
|
||||
currentPart = currentParts[i]
|
||||
}
|
||||
if i < len(latestParts) {
|
||||
latestPart = latestParts[i]
|
||||
}
|
||||
if latestPart > currentPart {
|
||||
return true
|
||||
}
|
||||
if latestPart < currentPart {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseVersionParts(version string) []int {
|
||||
func (channel ReleaseChannel) String() string {
|
||||
if channel == ReleaseChannelPreview {
|
||||
return string(ReleaseChannelPreview)
|
||||
}
|
||||
return string(ReleaseChannelStable)
|
||||
}
|
||||
|
||||
func isVersionNewer(current string, latest string) bool {
|
||||
currentInfo := parseVersionInfo(current)
|
||||
latestInfo := parseVersionInfo(latest)
|
||||
if currentInfo.IsDev {
|
||||
return latestInfo.Valid
|
||||
}
|
||||
if !currentInfo.Valid || !latestInfo.Valid {
|
||||
return false
|
||||
}
|
||||
return compareVersionInfo(currentInfo, latestInfo) < 0
|
||||
}
|
||||
|
||||
type versionInfo struct {
|
||||
Valid bool
|
||||
IsDev bool
|
||||
Numbers []int
|
||||
Prerelease []string
|
||||
}
|
||||
|
||||
func parseVersionInfo(version string) versionInfo {
|
||||
normalized := strings.TrimSpace(strings.TrimPrefix(version, "v"))
|
||||
if normalized == "" || normalized == "dev" {
|
||||
return nil
|
||||
return versionInfo{IsDev: strings.EqualFold(normalized, "dev")}
|
||||
}
|
||||
base := normalized
|
||||
prerelease := ""
|
||||
if separator := strings.IndexRune(normalized, '-'); separator >= 0 {
|
||||
base = normalized[:separator]
|
||||
prerelease = normalized[separator+1:]
|
||||
}
|
||||
|
||||
segments := strings.Split(normalized, ".")
|
||||
segments := strings.Split(base, ".")
|
||||
parts := make([]int, 0, len(segments))
|
||||
for _, segment := range segments {
|
||||
segment = strings.TrimSpace(segment)
|
||||
@@ -466,12 +582,100 @@ func parseVersionParts(version string) []int {
|
||||
}
|
||||
value, err := strconv.Atoi(numeric.String())
|
||||
if err != nil {
|
||||
parts = append(parts, 0)
|
||||
continue
|
||||
return versionInfo{}
|
||||
}
|
||||
parts = append(parts, value)
|
||||
}
|
||||
return parts
|
||||
info := versionInfo{Valid: len(parts) > 0, Numbers: parts}
|
||||
if prerelease != "" {
|
||||
info.Prerelease = splitPrereleaseIdentifiers(prerelease)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func splitPrereleaseIdentifiers(value string) []string {
|
||||
parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool {
|
||||
return r == '.' || r == '-'
|
||||
})
|
||||
filtered := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
filtered = append(filtered, part)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func compareVersionInfo(left versionInfo, right versionInfo) int {
|
||||
maxLen := len(left.Numbers)
|
||||
if len(right.Numbers) > maxLen {
|
||||
maxLen = len(right.Numbers)
|
||||
}
|
||||
for index := 0; index < maxLen; index++ {
|
||||
leftValue := 0
|
||||
rightValue := 0
|
||||
if index < len(left.Numbers) {
|
||||
leftValue = left.Numbers[index]
|
||||
}
|
||||
if index < len(right.Numbers) {
|
||||
rightValue = right.Numbers[index]
|
||||
}
|
||||
if leftValue < rightValue {
|
||||
return -1
|
||||
}
|
||||
if leftValue > rightValue {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
if len(left.Prerelease) == 0 && len(right.Prerelease) == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(left.Prerelease) == 0 {
|
||||
return 1
|
||||
}
|
||||
if len(right.Prerelease) == 0 {
|
||||
return -1
|
||||
}
|
||||
|
||||
maxLen = len(left.Prerelease)
|
||||
if len(right.Prerelease) > maxLen {
|
||||
maxLen = len(right.Prerelease)
|
||||
}
|
||||
for index := 0; index < maxLen; index++ {
|
||||
if index >= len(left.Prerelease) {
|
||||
return -1
|
||||
}
|
||||
if index >= len(right.Prerelease) {
|
||||
return 1
|
||||
}
|
||||
leftPart := left.Prerelease[index]
|
||||
rightPart := right.Prerelease[index]
|
||||
leftNumber, leftErr := strconv.Atoi(leftPart)
|
||||
rightNumber, rightErr := strconv.Atoi(rightPart)
|
||||
switch {
|
||||
case leftErr == nil && rightErr == nil:
|
||||
if leftNumber < rightNumber {
|
||||
return -1
|
||||
}
|
||||
if leftNumber > rightNumber {
|
||||
return 1
|
||||
}
|
||||
case leftErr == nil && rightErr != nil:
|
||||
return -1
|
||||
case leftErr != nil && rightErr == nil:
|
||||
return 1
|
||||
default:
|
||||
if leftPart < rightPart {
|
||||
return -1
|
||||
}
|
||||
if leftPart > rightPart {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func buildUploadedServerBinaryView(fileName string, currentVersion string, detectedVersion string, uploadedAt time.Time) *UploadedServerBinary {
|
||||
|
||||
@@ -38,6 +38,9 @@ func TestIsVersionNewer(t *testing.T) {
|
||||
{name: "same version", current: "v1.2.3", latest: "v1.2.3", expected: false},
|
||||
{name: "older remote", current: "v1.3.0", latest: "v1.2.9", expected: false},
|
||||
{name: "double digit segment", current: "v1.9.9", latest: "v1.10.0", expected: true},
|
||||
{name: "stable newer than prerelease", current: "v1.2.3-rc.1", latest: "v1.2.3", expected: true},
|
||||
{name: "prerelease not newer than same stable", current: "v1.2.3", latest: "v1.2.3-rc.1", expected: false},
|
||||
{name: "newer prerelease sequence", current: "v1.2.3-rc.1", latest: "v1.2.3-rc.2", expected: true},
|
||||
{name: "dev build", current: "dev", latest: "v0.4.0", expected: true},
|
||||
}
|
||||
|
||||
@@ -70,7 +73,7 @@ func TestBuildLatestServerReleaseView(t *testing.T) {
|
||||
Body: "release notes",
|
||||
HTMLURL: "https://github.com/Rain-kl/ATSFlare/releases/tag/v0.5.0",
|
||||
PublishedAt: "2026-03-11T00:00:00Z",
|
||||
})
|
||||
}, ReleaseChannelStable)
|
||||
|
||||
if view.CurrentVersion != "v0.4.0" {
|
||||
t.Fatalf("unexpected current version: %s", view.CurrentVersion)
|
||||
@@ -84,6 +87,9 @@ func TestBuildLatestServerReleaseView(t *testing.T) {
|
||||
if view.TagName != "v0.5.0" {
|
||||
t.Fatalf("unexpected tag name: %s", view.TagName)
|
||||
}
|
||||
if view.Channel != ReleaseChannelStable.String() {
|
||||
t.Fatalf("unexpected channel: %s", view.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLatestServerReleaseViewDevBuild(t *testing.T) {
|
||||
@@ -98,7 +104,7 @@ func TestBuildLatestServerReleaseViewDevBuild(t *testing.T) {
|
||||
|
||||
view := buildLatestServerReleaseView(&githubReleaseResponse{
|
||||
TagName: "v0.5.0",
|
||||
})
|
||||
}, ReleaseChannelStable)
|
||||
|
||||
if view.HasUpdate {
|
||||
t.Fatal("expected dev build not to report update availability")
|
||||
@@ -108,6 +114,31 @@ func TestBuildLatestServerReleaseViewDevBuild(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLatestServerReleaseViewPreview(t *testing.T) {
|
||||
originalVersion := common.Version
|
||||
common.Version = "v0.5.0-rc.1"
|
||||
t.Cleanup(func() {
|
||||
common.Version = originalVersion
|
||||
resetServerUpgradeTestState(t)
|
||||
})
|
||||
|
||||
view := buildLatestServerReleaseView(&githubReleaseResponse{
|
||||
TagName: "v0.5.0-rc.2",
|
||||
Prerelease: true,
|
||||
PublishedAt: "2026-03-12T00:00:00Z",
|
||||
}, ReleaseChannelPreview)
|
||||
|
||||
if !view.HasUpdate {
|
||||
t.Fatal("expected preview release to be newer")
|
||||
}
|
||||
if !view.Prerelease {
|
||||
t.Fatal("expected preview flag to be true")
|
||||
}
|
||||
if view.Channel != ReleaseChannelPreview.String() {
|
||||
t.Fatalf("unexpected channel: %s", view.Channel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadManualServerBinary(t *testing.T) {
|
||||
originalVersion := common.Version
|
||||
common.Version = "v0.4.0"
|
||||
|
||||
Reference in New Issue
Block a user