feat: add manual server binary upload and confirmation upgrade process

- Implemented UploadManualServerBinary endpoint for uploading server binaries and checking their versions.
- Added ConfirmManualServerUpgrade endpoint to confirm the upgrade with the uploaded binary.
- Updated API routes to include manual upload and upgrade confirmation.
- Enhanced service layer to handle manual binary uploads, version detection, and upgrade execution.
- Introduced new types for handling uploaded binary information.
- Updated frontend components to support manual binary upload and confirmation, including UI feedback for users.
- Modified documentation to reflect new manual upload and upgrade features.
This commit is contained in:
ryan
2026-03-12 10:34:31 +08:00
parent 29a0c64ba3
commit 93e43fb3b0
12 changed files with 1004 additions and 28 deletions
+2 -2
View File
@@ -45,9 +45,9 @@ ATSFlare 当前定位为内部自用的反向代理控制面,不面向外部
* 反向代理规则管理:一个域名对应一个源站地址,统一维护、统一发布
* 配置版本化:支持预览、发布、激活、历史回滚,版本不可变
* 节点接入:支持全局 `discovery_token` 首次接入,也支持节点专属 `agent_token`
* Agent 自动应用:周期性同步、落盘、`nginx -t`、`nginx -s reload`、失败自动回滚
* Agent 自动应用:周期性同步、落盘、`openresty -t`、`openresty -s reload`、失败自动回滚
* TLS 与域名管理:支持证书托管、域名资产维护、精确匹配与通配符匹配
* 运维能力:配置变更摘要、Agent 运行参数下发、Agent 自更新、Server 自升级
* 运维能力:配置变更摘要、Agent 运行参数下发、Agent 自更新、Server GitHub 自升级、Server 手动上传二进制确认升级
* 管理端 UI:基于 Next.js App Router + React 19 + Tailwind CSS 4 的新版前端
## 界面预览
+88
View File
@@ -3,10 +3,15 @@ package controller
import (
"atsflare/service"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
type confirmManualUpgradeRequest struct {
UploadToken string `json:"upload_token"`
}
// GetLatestRelease godoc
// @Summary Get latest GitHub release
// @Tags Update
@@ -52,3 +57,86 @@ func UpgradeServer(c *gin.Context) {
"data": release,
})
}
// UploadManualServerBinary godoc
// @Summary Upload server binary and inspect version before upgrade
// @Tags Update
// @Accept mpfd
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/update/manual-upload [post]
func UploadManualServerBinary(c *gin.Context) {
fileHeader, err := c.FormFile("binary")
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "请先选择要上传的服务端二进制文件。",
})
return
}
file, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "读取上传文件失败。",
})
return
}
defer func() {
_ = file.Close()
}()
info, err := service.UploadManualServerBinary(c.Request.Context(), fileHeader.Filename, file)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
message := strings.TrimSpace(info.ComparisonMessage)
if message == "" {
message = "已完成上传并检查升级包版本。"
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": message,
"data": info,
})
}
// ConfirmManualServerUpgrade godoc
// @Summary Confirm upgrade with previously uploaded server binary
// @Tags Update
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Router /api/update/manual-upgrade [post]
func ConfirmManualServerUpgrade(c *gin.Context) {
var request confirmManualUpgradeRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "升级确认参数无效。",
})
return
}
info, err := service.ConfirmManualServerUpgrade(request.UploadToken)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "服务升级任务已启动,确认无误后将自动重启。",
"data": info,
})
}
+2
View File
@@ -59,6 +59,8 @@ func SetApiRouter(router *gin.Engine) {
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
{
updateRoute.GET("/latest-release", controller.GetLatestRelease)
updateRoute.POST("/manual-upload", controller.UploadManualServerBinary)
updateRoute.POST("/manual-upgrade", controller.ConfirmManualServerUpgrade)
updateRoute.POST("/upgrade", controller.UpgradeServer)
}
fileRoute := apiRouter.Group("/file")
+194
View File
@@ -7,10 +7,13 @@ import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
@@ -105,3 +108,194 @@ func TestLatestReleaseProxy(t *testing.T) {
t.Fatalf("unexpected current_version: %#v", data["current_version"])
}
}
func loginRootAndBuildEngine(t *testing.T) (*gin.Engine, []*http.Cookie) {
t.Helper()
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginBody, err := json.Marshal(map[string]string{
"username": "root",
"password": "123456",
})
if err != nil {
t.Fatalf("failed to marshal login body: %v", err)
}
loginReq := httptest.NewRequest(http.MethodPost, "/api/user/login", bytes.NewReader(loginBody))
loginReq.Header.Set("Content-Type", "application/json")
loginRecorder := httptest.NewRecorder()
engine.ServeHTTP(loginRecorder, loginReq)
if loginRecorder.Code != http.StatusOK {
t.Fatalf("unexpected login status code: %d", loginRecorder.Code)
}
return engine, loginRecorder.Result().Cookies()
}
func fakeManualServerBinary(version string) (string, []byte) {
if runtime.GOOS == "windows" {
return "atsflare-server-test.cmd", []byte("@echo off\r\necho " + version + "\r\n")
}
return "atsflare-server-test.sh", []byte("#!/bin/sh\necho " + version + "\n")
}
func TestManualUploadRoute(t *testing.T) {
originalVersion := common.Version
common.Version = "v0.4.0"
t.Cleanup(func() {
common.Version = originalVersion
service.SetServerBinaryUpgradeExecutorForTest(nil)
service.SetServerUpgradeDispatchDelayForTest(500 * time.Millisecond)
})
engine, cookies := loginRootAndBuildEngine(t)
fileName, content := fakeManualServerBinary("v0.5.0")
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("binary", fileName)
if err != nil {
t.Fatalf("failed to create form file: %v", err)
}
if _, err = part.Write(content); err != nil {
t.Fatalf("failed to write upload content: %v", err)
}
if err = writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/update/manual-upload", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
for _, cookieValue := range cookies {
req.AddCookie(cookieValue)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("unexpected status code: %d", recorder.Code)
}
var resp apiResponse
if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.Success {
t.Fatalf("expected success response, got message: %s", resp.Message)
}
var data map[string]any
if err = json.Unmarshal(resp.Data, &data); err != nil {
t.Fatalf("failed to decode response data: %v", err)
}
if data["detected_version"] != "v0.5.0" {
t.Fatalf("unexpected detected_version: %#v", data["detected_version"])
}
if data["ready_to_upgrade"] != true {
t.Fatalf("expected ready_to_upgrade to be true: %#v", data["ready_to_upgrade"])
}
if data["upload_token"] == "" {
t.Fatal("expected upload_token to be returned")
}
}
func TestManualUpgradeConfirmRoute(t *testing.T) {
originalVersion := common.Version
originalExecutor := service.ServerBinaryUpgradeExecutorForTest()
originalDelay := service.ServerUpgradeDispatchDelayForTest()
common.Version = "v0.4.0"
called := make(chan string, 1)
service.SetServerBinaryUpgradeExecutorForTest(func(execPath string, tempPath string) error {
called <- tempPath
return nil
})
service.SetServerUpgradeDispatchDelayForTest(0)
t.Cleanup(func() {
common.Version = originalVersion
service.SetServerBinaryUpgradeExecutorForTest(originalExecutor)
service.SetServerUpgradeDispatchDelayForTest(originalDelay)
})
engine, cookies := loginRootAndBuildEngine(t)
fileName, content := fakeManualServerBinary("v0.5.0")
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("binary", fileName)
if err != nil {
t.Fatalf("failed to create form file: %v", err)
}
if _, err = part.Write(content); err != nil {
t.Fatalf("failed to write upload content: %v", err)
}
if err = writer.Close(); err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
uploadReq := httptest.NewRequest(http.MethodPost, "/api/update/manual-upload", body)
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
for _, cookieValue := range cookies {
uploadReq.AddCookie(cookieValue)
}
uploadRecorder := httptest.NewRecorder()
engine.ServeHTTP(uploadRecorder, uploadReq)
if uploadRecorder.Code != http.StatusOK {
t.Fatalf("unexpected upload status code: %d", uploadRecorder.Code)
}
var uploadResp apiResponse
if err = json.Unmarshal(uploadRecorder.Body.Bytes(), &uploadResp); err != nil {
t.Fatalf("failed to decode upload response: %v", err)
}
if !uploadResp.Success {
t.Fatalf("expected upload success, got message: %s", uploadResp.Message)
}
var uploadData map[string]any
if err = json.Unmarshal(uploadResp.Data, &uploadData); err != nil {
t.Fatalf("failed to decode upload response data: %v", err)
}
uploadToken, _ := uploadData["upload_token"].(string)
if uploadToken == "" {
t.Fatal("expected upload token in upload response")
}
confirmBody, err := json.Marshal(map[string]string{"upload_token": uploadToken})
if err != nil {
t.Fatalf("failed to marshal confirm body: %v", err)
}
confirmReq := httptest.NewRequest(http.MethodPost, "/api/update/manual-upgrade", bytes.NewReader(confirmBody))
confirmReq.Header.Set("Content-Type", "application/json")
for _, cookieValue := range cookies {
confirmReq.AddCookie(cookieValue)
}
confirmRecorder := httptest.NewRecorder()
engine.ServeHTTP(confirmRecorder, confirmReq)
if confirmRecorder.Code != http.StatusOK {
t.Fatalf("unexpected confirm status code: %d", confirmRecorder.Code)
}
var confirmResp apiResponse
if err = json.Unmarshal(confirmRecorder.Body.Bytes(), &confirmResp); err != nil {
t.Fatalf("failed to decode confirm response: %v", err)
}
if !confirmResp.Success {
t.Fatalf("expected confirm success, got message: %s", confirmResp.Message)
}
select {
case tempPath := <-called:
if tempPath == "" {
t.Fatal("expected manual upgrade executor to receive temp path")
}
case <-time.After(time.Second):
t.Fatal("expected manual upgrade executor to be called")
}
}
+284 -2
View File
@@ -3,12 +3,15 @@ package service
import (
"atsflare/common"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
@@ -28,6 +31,15 @@ var serverUpgradeState struct {
inProgress bool
}
var manualServerBinaryState struct {
sync.Mutex
candidate *manualServerBinaryCandidate
}
var serverBinaryUpgradeExecutor = replaceAndRestartServer
var serverUpgradeDispatchDelay = 500 * time.Millisecond
type LatestServerRelease struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
@@ -58,6 +70,28 @@ type preparedServerUpgrade struct {
execPath string
}
type UploadedServerBinary struct {
UploadToken string `json:"upload_token"`
FileName string `json:"file_name"`
DetectedVersion string `json:"detected_version"`
CurrentVersion string `json:"current_version"`
HasUpdate bool `json:"has_update"`
UpgradeSupported bool `json:"upgrade_supported"`
ReadyToUpgrade bool `json:"ready_to_upgrade"`
ComparisonMessage string `json:"comparison_message"`
UploadedAt time.Time `json:"uploaded_at"`
}
type manualServerBinaryCandidate struct {
UploadToken string
FileName string
DetectedVersion string
CurrentVersion string
TempPath string
ExecPath string
UploadedAt time.Time
}
func GetLatestServerRelease(ctx context.Context) (*LatestServerRelease, error) {
release, err := fetchLatestRelease(ctx)
if err != nil {
@@ -85,7 +119,7 @@ func ScheduleServerUpgrade() (*LatestServerRelease, error) {
prepared.release.InProgress = true
go func(task *preparedServerUpgrade) {
time.Sleep(500 * time.Millisecond)
time.Sleep(serverUpgradeDispatchDelay)
if err := executeServerUpgrade(task); err != nil {
log.Printf("server self-update failed: %v", err)
serverUpgradeState.Lock()
@@ -97,6 +131,123 @@ func ScheduleServerUpgrade() (*LatestServerRelease, error) {
return prepared.release, nil
}
func UploadManualServerBinary(ctx context.Context, fileName string, reader io.Reader) (*UploadedServerBinary, error) {
serverUpgradeState.Lock()
inProgress := serverUpgradeState.inProgress
serverUpgradeState.Unlock()
if inProgress {
return nil, fmt.Errorf("服务升级已在执行中,请稍后再试")
}
if strings.TrimSpace(fileName) == "" {
return nil, fmt.Errorf("缺少上传文件名")
}
if reader == nil {
return nil, fmt.Errorf("缺少上传文件内容")
}
tempPath, err := persistUploadedServerBinary(fileName, reader)
if err != nil {
return nil, err
}
detectedVersion, err := detectUploadedServerBinaryVersion(ctx, tempPath)
if err != nil {
_ = os.Remove(tempPath)
return nil, err
}
currentVersion := strings.TrimSpace(common.Version)
uploadedAt := time.Now()
info := buildUploadedServerBinaryView(fileName, currentVersion, detectedVersion, uploadedAt)
if !info.ReadyToUpgrade {
_ = os.Remove(tempPath)
return info, nil
}
execPath, err := os.Executable()
if err != nil {
_ = os.Remove(tempPath)
return nil, fmt.Errorf("获取当前服务程序路径失败: %v", err)
}
if err = verifyExecutableDirectoryWritable(execPath); err != nil {
_ = os.Remove(tempPath)
return nil, err
}
uploadToken, err := newUpgradeToken()
if err != nil {
_ = os.Remove(tempPath)
return nil, fmt.Errorf("生成升级令牌失败: %v", err)
}
manualServerBinaryState.Lock()
cleanupManualServerBinaryCandidateLocked()
manualServerBinaryState.candidate = &manualServerBinaryCandidate{
UploadToken: uploadToken,
FileName: fileName,
DetectedVersion: detectedVersion,
CurrentVersion: currentVersion,
TempPath: tempPath,
ExecPath: execPath,
UploadedAt: uploadedAt,
}
manualServerBinaryState.Unlock()
info.UploadToken = uploadToken
return info, nil
}
func ConfirmManualServerUpgrade(uploadToken string) (*UploadedServerBinary, error) {
uploadToken = strings.TrimSpace(uploadToken)
if uploadToken == "" {
return nil, fmt.Errorf("缺少升级令牌")
}
serverUpgradeState.Lock()
if serverUpgradeState.inProgress {
serverUpgradeState.Unlock()
return nil, fmt.Errorf("服务升级已在执行中,请稍后再试")
}
serverUpgradeState.Unlock()
manualServerBinaryState.Lock()
candidate := manualServerBinaryState.candidate
if candidate == nil {
manualServerBinaryState.Unlock()
return nil, fmt.Errorf("未找到待确认的上传升级包,请重新上传")
}
if candidate.UploadToken != uploadToken {
manualServerBinaryState.Unlock()
return nil, fmt.Errorf("升级令牌无效或已过期,请重新上传")
}
manualServerBinaryState.candidate = nil
manualServerBinaryState.Unlock()
info := buildUploadedServerBinaryView(candidate.FileName, candidate.CurrentVersion, candidate.DetectedVersion, candidate.UploadedAt)
info.UploadToken = candidate.UploadToken
if !info.ReadyToUpgrade {
_ = os.Remove(candidate.TempPath)
return nil, fmt.Errorf("当前上传的二进制不满足升级条件")
}
serverUpgradeState.Lock()
serverUpgradeState.inProgress = true
serverUpgradeState.Unlock()
go func(task *manualServerBinaryCandidate) {
time.Sleep(serverUpgradeDispatchDelay)
if err := executeManualServerUpgrade(task); err != nil {
log.Printf("server manual upgrade failed: %v", err)
serverUpgradeState.Lock()
serverUpgradeState.inProgress = false
serverUpgradeState.Unlock()
_ = os.Remove(task.TempPath)
}
}(candidate)
return info, nil
}
func fetchLatestRelease(ctx context.Context) (*githubReleaseResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil)
if err != nil {
@@ -244,7 +395,12 @@ func executeServerUpgrade(task *preparedServerUpgrade) error {
}
common.SysLog("server self-update starting: from=" + strings.TrimSpace(common.Version) + " to=" + task.release.TagName)
return replaceAndRestartServer(task.execPath, tmpPath)
return serverBinaryUpgradeExecutor(task.execPath, tmpPath)
}
func executeManualServerUpgrade(task *manualServerBinaryCandidate) error {
common.SysLog("server manual self-update starting: from=" + strings.TrimSpace(task.CurrentVersion) + " to=" + strings.TrimSpace(task.DetectedVersion))
return serverBinaryUpgradeExecutor(task.ExecPath, task.TempPath)
}
func serverAssetName(goos string, goarch string) string {
@@ -318,6 +474,109 @@ func parseVersionParts(version string) []int {
return parts
}
func buildUploadedServerBinaryView(fileName string, currentVersion string, detectedVersion string, uploadedAt time.Time) *UploadedServerBinary {
upgradeSupported := isManualServerUpgradeSupported(currentVersion)
hasUpdate := false
comparisonMessage := ""
switch {
case !upgradeSupported:
comparisonMessage = "当前服务版本不支持手动升级确认流程"
case normalizeVersion(currentVersion) == normalizeVersion(detectedVersion):
comparisonMessage = "上传二进制与当前服务版本一致,无需升级"
case isVersionNewer(currentVersion, detectedVersion):
hasUpdate = true
comparisonMessage = fmt.Sprintf("检测到可升级版本:%s -> %s", strings.TrimSpace(currentVersion), strings.TrimSpace(detectedVersion))
default:
comparisonMessage = "上传二进制版本不高于当前服务版本,已拒绝升级"
}
return &UploadedServerBinary{
FileName: strings.TrimSpace(fileName),
DetectedVersion: strings.TrimSpace(detectedVersion),
CurrentVersion: strings.TrimSpace(currentVersion),
HasUpdate: hasUpdate,
UpgradeSupported: upgradeSupported,
ReadyToUpgrade: upgradeSupported && hasUpdate,
ComparisonMessage: comparisonMessage,
UploadedAt: uploadedAt,
}
}
func isManualServerUpgradeSupported(currentVersion string) bool {
normalized := strings.TrimSpace(strings.TrimPrefix(currentVersion, "v"))
return normalized != "" && !strings.EqualFold(normalized, "dev")
}
func persistUploadedServerBinary(fileName string, reader io.Reader) (string, error) {
suffix := filepath.Ext(strings.TrimSpace(fileName))
if runtime.GOOS == "windows" && suffix == "" {
suffix = ".exe"
}
tempFile, err := os.CreateTemp("", "atsflare-server-manual-upgrade-*"+suffix)
if err != nil {
return "", fmt.Errorf("创建临时升级文件失败: %v", err)
}
tempPath := tempFile.Name()
if _, err = io.Copy(tempFile, reader); err != nil {
_ = tempFile.Close()
_ = os.Remove(tempPath)
return "", fmt.Errorf("写入上传二进制失败: %v", err)
}
if err = tempFile.Close(); err != nil {
_ = os.Remove(tempPath)
return "", fmt.Errorf("关闭临时升级文件失败: %v", err)
}
if err = os.Chmod(tempPath, 0o755); err != nil && runtime.GOOS != "windows" {
_ = os.Remove(tempPath)
return "", fmt.Errorf("设置临时升级文件权限失败: %v", err)
}
return tempPath, nil
}
func detectUploadedServerBinaryVersion(ctx context.Context, filePath string) (string, error) {
commandCtx := ctx
if commandCtx == nil {
commandCtx = context.Background()
}
cmd := exec.CommandContext(commandCtx, filePath, "--version")
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("检查上传二进制版本失败: %w: %s", err, strings.TrimSpace(string(output)))
}
version := strings.TrimSpace(string(output))
if version == "" {
return "", fmt.Errorf("上传二进制未返回有效版本号")
}
for _, line := range strings.Split(version, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
return trimmed, nil
}
}
return "", fmt.Errorf("上传二进制未返回有效版本号")
}
func cleanupManualServerBinaryCandidateLocked() {
if manualServerBinaryState.candidate == nil {
return
}
_ = os.Remove(manualServerBinaryState.candidate.TempPath)
manualServerBinaryState.candidate = nil
}
func newUpgradeToken() (string, error) {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return hex.EncodeToString(buffer), nil
}
func normalizeVersion(version string) string {
return strings.TrimSpace(strings.TrimPrefix(version, "v"))
}
func UpdateHTTPClientForTest() *http.Client {
return updateHTTPClient
}
@@ -325,3 +584,26 @@ func UpdateHTTPClientForTest() *http.Client {
func SetUpdateHTTPClientForTest(client *http.Client) {
updateHTTPClient = client
}
func ServerBinaryUpgradeExecutorForTest() func(string, string) error {
return serverBinaryUpgradeExecutor
}
func SetServerBinaryUpgradeExecutorForTest(executor func(string, string) error) {
if executor == nil {
serverBinaryUpgradeExecutor = replaceAndRestartServer
return
}
serverBinaryUpgradeExecutor = executor
}
func ServerUpgradeDispatchDelayForTest() time.Duration {
return serverUpgradeDispatchDelay
}
func SetServerUpgradeDispatchDelayForTest(delay time.Duration) {
if delay < 0 {
delay = 0
}
serverUpgradeDispatchDelay = delay
}
+128
View File
@@ -2,9 +2,31 @@ package service
import (
"atsflare/common"
"bytes"
"context"
"os"
"runtime"
"testing"
"time"
)
func resetServerUpgradeTestState(t *testing.T) {
t.Helper()
serverUpgradeState.Lock()
serverUpgradeState.inProgress = false
serverUpgradeState.Unlock()
manualServerBinaryState.Lock()
cleanupManualServerBinaryCandidateLocked()
manualServerBinaryState.Unlock()
}
func fakeServerBinaryFixture(version string) (string, []byte) {
if runtime.GOOS == "windows" {
return "atsflare-server-test.cmd", []byte("@echo off\r\necho " + version + "\r\n")
}
return "atsflare-server-test.sh", []byte("#!/bin/sh\necho " + version + "\n")
}
func TestIsVersionNewer(t *testing.T) {
testCases := []struct {
name string
@@ -85,3 +107,109 @@ func TestBuildLatestServerReleaseViewDevBuild(t *testing.T) {
t.Fatal("expected dev build not to support self-upgrade")
}
}
func TestUploadManualServerBinary(t *testing.T) {
originalVersion := common.Version
common.Version = "v0.4.0"
t.Cleanup(func() {
common.Version = originalVersion
resetServerUpgradeTestState(t)
})
fileName, content := fakeServerBinaryFixture("v0.5.0")
info, err := UploadManualServerBinary(context.Background(), fileName, bytes.NewReader(content))
if err != nil {
t.Fatalf("expected upload to succeed: %v", err)
}
if !info.ReadyToUpgrade {
t.Fatal("expected uploaded binary to be ready for upgrade")
}
if info.UploadToken == "" {
t.Fatal("expected upload token to be returned")
}
if info.DetectedVersion != "v0.5.0" {
t.Fatalf("unexpected detected version: %s", info.DetectedVersion)
}
manualServerBinaryState.Lock()
candidate := manualServerBinaryState.candidate
manualServerBinaryState.Unlock()
if candidate == nil {
t.Fatal("expected manual upgrade candidate to be stored")
}
if _, err := os.Stat(candidate.TempPath); err != nil {
t.Fatalf("expected temporary binary to exist: %v", err)
}
if candidate.UploadToken != info.UploadToken {
t.Fatalf("unexpected stored upload token: %s", candidate.UploadToken)
}
}
func TestUploadManualServerBinaryRejectsSameVersion(t *testing.T) {
originalVersion := common.Version
common.Version = "v0.5.0"
t.Cleanup(func() {
common.Version = originalVersion
resetServerUpgradeTestState(t)
})
fileName, content := fakeServerBinaryFixture("v0.5.0")
info, err := UploadManualServerBinary(context.Background(), fileName, bytes.NewReader(content))
if err != nil {
t.Fatalf("expected upload to succeed: %v", err)
}
if info.ReadyToUpgrade {
t.Fatal("expected same-version upload not to be upgradeable")
}
if info.UploadToken != "" {
t.Fatal("expected same-version upload not to issue a token")
}
manualServerBinaryState.Lock()
defer manualServerBinaryState.Unlock()
if manualServerBinaryState.candidate != nil {
t.Fatal("expected no pending manual upgrade candidate")
}
}
func TestConfirmManualServerUpgrade(t *testing.T) {
originalVersion := common.Version
originalExecutor := ServerBinaryUpgradeExecutorForTest()
originalDelay := ServerUpgradeDispatchDelayForTest()
common.Version = "v0.4.0"
called := make(chan string, 1)
SetServerBinaryUpgradeExecutorForTest(func(execPath string, tempPath string) error {
called <- tempPath
return nil
})
SetServerUpgradeDispatchDelayForTest(0)
t.Cleanup(func() {
common.Version = originalVersion
SetServerBinaryUpgradeExecutorForTest(originalExecutor)
SetServerUpgradeDispatchDelayForTest(originalDelay)
resetServerUpgradeTestState(t)
})
fileName, content := fakeServerBinaryFixture("v0.5.0")
info, err := UploadManualServerBinary(context.Background(), fileName, bytes.NewReader(content))
if err != nil {
t.Fatalf("expected upload to succeed: %v", err)
}
confirmed, err := ConfirmManualServerUpgrade(info.UploadToken)
if err != nil {
t.Fatalf("expected confirm to succeed: %v", err)
}
if confirmed.UploadToken != info.UploadToken {
t.Fatalf("unexpected confirmed upload token: %s", confirmed.UploadToken)
}
select {
case tempPath := <-called:
if tempPath == "" {
t.Fatal("expected upgrade executor to receive temp path")
}
case <-time.After(time.Second):
t.Fatal("expected manual upgrade executor to be called")
}
}
@@ -7,8 +7,14 @@ import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/providers/auth-provider';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { getPublicStatus } from '@/features/auth/api/public';
import { getLatestRelease, upgradeServer } from '@/features/update/api/update';
import {
confirmManualServerUpgrade,
getLatestRelease,
upgradeServer,
uploadServerBinary,
} from '@/features/update/api/update';
import { VersionUpgradeModal } from '@/features/update/components/version-upgrade-modal';
import type { UploadedServerBinaryInfo } from '@/features/update/types';
import { publicEnv } from '@/lib/env/public-env';
import { useAppShellStore } from '@/store/app-shell';
@@ -26,6 +32,14 @@ export function DashboardTopbar() {
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
const [versionFeedback, setVersionFeedback] = useState<string | null>(null);
const [manualUpgradeStatus, setManualUpgradeStatus] = useState<string | null>(
null,
);
const [manualUpgradeError, setManualUpgradeError] = useState<string | null>(
null,
);
const [uploadedBinary, setUploadedBinary] =
useState<UploadedServerBinaryInfo | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const isRoot = (user?.role ?? 0) >= 100;
@@ -44,6 +58,9 @@ export function DashboardTopbar() {
const upgradeMutation = useMutation({
mutationFn: upgradeServer,
onSuccess: (release) => {
setUploadedBinary(null);
setManualUpgradeStatus(null);
setManualUpgradeError(null);
setVersionFeedback(
`服务升级任务已启动,目标版本 ${release.tag_name}。页面可能短暂不可用。`,
);
@@ -56,6 +73,44 @@ export function DashboardTopbar() {
},
});
const uploadBinaryMutation = useMutation({
mutationFn: uploadServerBinary,
onSuccess: (candidate) => {
setVersionFeedback(null);
setManualUpgradeError(null);
setUploadedBinary(candidate);
setManualUpgradeStatus(candidate.comparison_message);
},
onError: (error) => {
setUploadedBinary(null);
setManualUpgradeStatus(null);
setManualUpgradeError(
error instanceof Error ? error.message : '上传升级包失败,请稍后重试。',
);
},
});
const confirmManualUpgradeMutation = useMutation({
mutationFn: confirmManualServerUpgrade,
onSuccess: (candidate) => {
setVersionFeedback(null);
setManualUpgradeError(null);
setUploadedBinary(candidate);
setManualUpgradeStatus(
`手动升级任务已启动,目标版本 ${candidate.detected_version}。页面可能短暂不可用。`,
);
void latestReleaseQuery.refetch();
},
onError: (error) => {
setManualUpgradeStatus(null);
setManualUpgradeError(
error instanceof Error
? error.message
: '确认手动升级失败,请稍后重试。',
);
},
});
useEffect(() => {
if (!isUserMenuOpen) {
return;
@@ -100,6 +155,8 @@ export function DashboardTopbar() {
const handleOpenVersionModal = () => {
setVersionFeedback(null);
setManualUpgradeStatus(null);
setManualUpgradeError(null);
setIsVersionModalOpen(true);
if (isRoot) {
void latestReleaseQuery.refetch();
@@ -108,9 +165,29 @@ export function DashboardTopbar() {
const handleUpgrade = () => {
setVersionFeedback(null);
setManualUpgradeStatus(null);
setManualUpgradeError(null);
upgradeMutation.mutate();
};
const handleUploadBinary = (binary: File) => {
setManualUpgradeStatus(null);
setManualUpgradeError(null);
uploadBinaryMutation.mutate(binary);
};
const handleConfirmManualUpgrade = () => {
if (!uploadedBinary?.upload_token) {
setManualUpgradeStatus(null);
setManualUpgradeError('请先上传并检查升级包。');
return;
}
setVersionFeedback(null);
setManualUpgradeStatus(null);
setManualUpgradeError(null);
confirmManualUpgradeMutation.mutate(uploadedBinary.upload_token);
};
const release = latestReleaseQuery.data;
const hasUpdate = Boolean(isRoot && release?.has_update);
const currentVersion = publicStatusQuery.data?.version || 'unknown';
@@ -127,6 +204,7 @@ export function DashboardTopbar() {
? latestReleaseQuery.error.message
: '版本检查失败,请稍后重试。'
: undefined);
const manualUpgradeErrorMessage = manualUpgradeError ?? undefined;
return (
<>
@@ -206,11 +284,16 @@ export function DashboardTopbar() {
frontendVersion={publicEnv.appVersion}
startTime={publicStatusQuery.data?.start_time}
release={release}
uploadedBinary={uploadedBinary}
isLoading={latestReleaseQuery.isLoading && !release && isRoot}
errorMessage={versionErrorMessage}
releaseErrorMessage={versionErrorMessage}
manualStatusMessage={manualUpgradeStatus ?? undefined}
manualErrorMessage={manualUpgradeErrorMessage}
canUpgrade={isRoot}
isChecking={latestReleaseQuery.isFetching}
isUpgrading={upgradeMutation.isPending}
isUploadingBinary={uploadBinaryMutation.isPending}
isConfirmingManualUpgrade={confirmManualUpgradeMutation.isPending}
onRefresh={() => {
setVersionFeedback(null);
if (isRoot) {
@@ -218,6 +301,8 @@ export function DashboardTopbar() {
}
}}
onUpgrade={handleUpgrade}
onUploadBinary={handleUploadBinary}
onConfirmManualUpgrade={handleConfirmManualUpgrade}
/>
</>
);
+21 -1
View File
@@ -1,6 +1,9 @@
import { apiRequest } from '@/lib/api/client';
import type { LatestReleaseInfo } from '@/features/update/types';
import type {
LatestReleaseInfo,
UploadedServerBinaryInfo,
} from '@/features/update/types';
export function getLatestRelease() {
return apiRequest<LatestReleaseInfo>('/update/latest-release');
@@ -11,3 +14,20 @@ export function upgradeServer() {
method: 'POST',
});
}
export function uploadServerBinary(binary: File) {
const formData = new FormData();
formData.append('binary', binary);
return apiRequest<UploadedServerBinaryInfo>('/update/manual-upload', {
method: 'POST',
body: formData,
});
}
export function confirmManualServerUpgrade(uploadToken: string) {
return apiRequest<UploadedServerBinaryInfo>('/update/manual-upgrade', {
method: 'POST',
body: JSON.stringify({ upload_token: uploadToken }),
});
}
@@ -1,6 +1,7 @@
'use client';
import { marked } from 'marked';
import { useEffect, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
@@ -8,9 +9,14 @@ import { LoadingState } from '@/components/feedback/loading-state';
import { AppCard } from '@/components/ui/app-card';
import { AppModal } from '@/components/ui/app-modal';
import { StatusBadge } from '@/components/ui/status-badge';
import type { LatestReleaseInfo } from '@/features/update/types';
import type {
LatestReleaseInfo,
UploadedServerBinaryInfo,
} from '@/features/update/types';
import {
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
@@ -22,13 +28,20 @@ interface VersionUpgradeModalProps {
frontendVersion: string;
startTime?: number;
release: LatestReleaseInfo | null | undefined;
uploadedBinary: UploadedServerBinaryInfo | null;
isLoading: boolean;
errorMessage?: string;
releaseErrorMessage?: string;
manualStatusMessage?: string;
manualErrorMessage?: string;
canUpgrade: boolean;
isChecking: boolean;
isUpgrading: boolean;
isUploadingBinary: boolean;
isConfirmingManualUpgrade: boolean;
onRefresh: () => void;
onUpgrade: () => void;
onUploadBinary: (file: File) => void;
onConfirmManualUpgrade: () => void;
}
function getUpgradeBadge(release: LatestReleaseInfo | null | undefined) {
@@ -51,33 +64,75 @@ export function VersionUpgradeModal({
frontendVersion,
startTime,
release,
uploadedBinary,
isLoading,
errorMessage,
releaseErrorMessage,
manualStatusMessage,
manualErrorMessage,
canUpgrade,
isChecking,
isUpgrading,
isUploadingBinary,
isConfirmingManualUpgrade,
onRefresh,
onUpgrade,
onUploadBinary,
onConfirmManualUpgrade,
}: VersionUpgradeModalProps) {
const upgradeBadge = getUpgradeBadge(release);
const [selectedBinary, setSelectedBinary] = useState<File | null>(null);
useEffect(() => {
if (!isOpen) {
setSelectedBinary(null);
}
}, [isOpen]);
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title="版本"
description="在这里检查 GitHub 最新版本,并直接触发 Server 自升级。升级开始后服务会短暂重启。"
description="在这里检查 GitHub 最新版本,或手动上传 Server 二进制并确认升级。升级开始后服务会短暂重启。"
size="lg"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={onRefresh}
disabled={isChecking || isUpgrading}
>
{isChecking ? '检查中...' : '检查更新'}
</SecondaryButton>
{canUpgrade ? (
canUpgrade ? (
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={onRefresh}
disabled={isChecking || isUpgrading || isUploadingBinary}
>
{isChecking ? '检查中...' : '检查更新'}
</SecondaryButton>
<PrimaryButton
type="button"
onClick={() => {
if (selectedBinary) {
onUploadBinary(selectedBinary);
}
}}
disabled={
!selectedBinary ||
isUploadingBinary ||
isConfirmingManualUpgrade
}
>
{isUploadingBinary ? '上传检查中...' : '上传并检查'}
</PrimaryButton>
<PrimaryButton
type="button"
onClick={onConfirmManualUpgrade}
disabled={
!uploadedBinary?.ready_to_upgrade ||
!uploadedBinary.upload_token ||
isConfirmingManualUpgrade ||
isUploadingBinary ||
isUpgrading
}
>
{isConfirmingManualUpgrade ? '升级中...' : '确认手动升级'}
</PrimaryButton>
<PrimaryButton
type="button"
onClick={onUpgrade}
@@ -85,7 +140,9 @@ export function VersionUpgradeModal({
!release?.has_update ||
release.in_progress ||
isUpgrading ||
!release.upgrade_supported
!release.upgrade_supported ||
isUploadingBinary ||
isConfirmingManualUpgrade
}
>
{isUpgrading
@@ -94,8 +151,8 @@ export function VersionUpgradeModal({
? '升级中...'
: '立即升级'}
</PrimaryButton>
) : null}
</div>
</div>
) : undefined
}
>
<div className="space-y-6">
@@ -129,16 +186,16 @@ export function VersionUpgradeModal({
</div>
{isLoading ? <LoadingState /> : null}
{!isLoading && errorMessage ? (
<ErrorState title="版本检查失败" description={errorMessage} />
{!isLoading && releaseErrorMessage ? (
<ErrorState title="版本检查失败" description={releaseErrorMessage} />
) : null}
{!isLoading && !errorMessage && !release ? (
{!isLoading && !releaseErrorMessage && !release ? (
<EmptyState
title="尚未检查更新"
description="点击“检查更新”后会在这里展示最新 GitHub Release 信息。"
/>
) : null}
{!isLoading && !errorMessage && release ? (
{!isLoading && !releaseErrorMessage && release ? (
<AppCard
title={`GitHub Release · ${release.tag_name}`}
description={
@@ -182,6 +239,113 @@ export function VersionUpgradeModal({
</div>
</AppCard>
) : null}
{canUpgrade ? (
<AppCard
title="手动升级"
description="上传服务端二进制后,服务端会先检查版本,再由你确认是否执行升级。"
>
<div className="space-y-4">
<ResourceField
label="服务端二进制"
hint="支持上传已编译好的当前平台 Server 可执行文件。"
>
<ResourceInput
type="file"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
setSelectedBinary(file);
}}
disabled={isUploadingBinary || isConfirmingManualUpgrade}
/>
</ResourceField>
{manualErrorMessage ? (
<ErrorState
title="手动升级检查失败"
description={manualErrorMessage}
/>
) : null}
{!manualErrorMessage && manualStatusMessage ? (
<div className="rounded-2xl border border-[var(--status-warning-border)] bg-[var(--status-warning-soft)] px-4 py-3 text-sm text-[var(--status-warning-foreground)]">
{manualStatusMessage}
</div>
) : null}
{uploadedBinary ? (
<div className="space-y-4 rounded-3xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge
label={
uploadedBinary.ready_to_upgrade
? '可确认升级'
: uploadedBinary.has_update
? '待确认'
: '不可升级'
}
variant={
uploadedBinary.ready_to_upgrade ? 'warning' : 'info'
}
/>
{!uploadedBinary.upgrade_supported ? (
<StatusBadge
label="当前版本不支持手动升级"
variant="danger"
/>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
文件名
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.file_name}
</p>
</div>
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
上传时间
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.uploaded_at
? formatDateTime(uploadedBinary.uploaded_at)
: '未知'}
</p>
</div>
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
当前版本
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.current_version}
</p>
</div>
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
上传版本
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{uploadedBinary.detected_version}
</p>
</div>
</div>
<p className="text-sm text-[var(--foreground-secondary)]">
{uploadedBinary.comparison_message}
</p>
</div>
) : (
<EmptyState
title="尚未上传升级包"
description="上传后会在这里展示识别出的版本信息,并允许你确认升级。"
/>
)}
</div>
</AppCard>
) : null}
</div>
</AppModal>
);
+12
View File
@@ -8,3 +8,15 @@ export interface LatestReleaseInfo {
upgrade_supported: boolean;
in_progress: boolean;
}
export interface UploadedServerBinaryInfo {
upload_token: string;
file_name: string;
detected_version: string;
current_version: string;
has_update: boolean;
upgrade_supported: boolean;
ready_to_upgrade: boolean;
comparison_message: string;
uploaded_at: string;
}
+1
View File
@@ -96,6 +96,7 @@ docker compose up -d
* Root 用户可在管理端顶栏点击「版本」检查 GitHub 最新 Release
* 当前运行的是 Release 二进制且二进制目录可写时,可直接在弹窗内触发 Server 自升级
* 自升级会下载匹配当前平台的 `atsflare-server-*` 资产,替换当前二进制并自动重启进程
* 也可在同一弹窗中手动上传 Server 二进制,服务端先检测上传文件版本,前端确认后再执行替换与重启
### 2.4 首次登录
+1 -1
View File
@@ -28,7 +28,7 @@ ATSFlare 当前定位为内部自用的反向代理控制面,不面向外部
* 配置变更摘要
* Agent 运行参数下发
* Agent 自我更新与一键部署
* Server 版本检查与自升级
* Server 版本检查、GitHub 自升级与手动上传二进制确认升级
* 新版管理端 UI、主题切换与统一交互框架
默认工作方式: