fix: make web updates survive container restart
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
## 本次更新
|
||||||
|
|
||||||
|
### 修复网页更新出现 502
|
||||||
|
|
||||||
|
- 修复后台点击“立即更新”后,Watchtower 在替换业务容器时切断原更新请求,导致页面错误显示 `502 Bad Gateway` 或“更新失败”的问题。
|
||||||
|
- 更新接口现在会先完成数据库备份并向网页返回 `202 Accepted`,再异步触发容器更新,避免旧容器停止时丢失响应。
|
||||||
|
- 前端遇到更新期间的 `502`、`503`、`504`、网络中断或请求超时时,会继续轮询服务健康状态;确认目标版本启动后自动刷新页面。
|
||||||
|
- Watchtower 调用等待时间延长到 10 分钟,兼容首次拉取较大镜像或网络较慢的服务器。
|
||||||
|
|
||||||
|
### 更新说明
|
||||||
|
|
||||||
|
- 网页更新仍会在替换容器期间产生数秒正常中断,页面会显示“正在重启服务”,恢复后自动刷新。
|
||||||
|
- 更新前仍会自动备份 SQLite 数据库,现有邮件、账号、域名、证书和配置不会删除。
|
||||||
|
- 从 `v1.2.6` 更新到本版时,旧页面尚未包含此次容错逻辑,建议在服务器运行 `sudo newszxcn-email update` 完成这一次升级;进入 `v1.2.7` 后,后续版本可正常使用网页更新。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- 新增异步更新回归测试:即使 Watchtower 更新请求保持阻塞,网页也必须先收到更新已受理响应。
|
||||||
|
- 已通过 Go API 测试、前端生产构建和 shadcn/ui 检查。
|
||||||
|
|
||||||
|
**完整更新日志**:[v1.2.6...v1.2.7](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.6...v1.2.7)
|
||||||
@@ -62,6 +62,8 @@ bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/i
|
|||||||
|
|
||||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||||
|
|
||||||
|
更新期间容器会短暂重启。接口会先向页面确认更新已受理,再异步替换容器;页面遇到临时 `502/503/504` 或网络中断时会继续检查服务状态,不会立即误报更新失败。
|
||||||
|
|
||||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||||
|
|
||||||
### 命令行更新
|
### 命令行更新
|
||||||
|
|||||||
@@ -81,12 +81,6 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.triggerUpdateService(r.Context()); err != nil {
|
|
||||||
a.log.Error("trigger system update", "error", err)
|
|
||||||
respondError(w, http.StatusBadGateway, "failed to start update")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
||||||
respondJSON(w, http.StatusAccepted, map[string]any{
|
respondJSON(w, http.StatusAccepted, map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
@@ -94,6 +88,7 @@ func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
"targetVersion": info.LatestVersion,
|
"targetVersion": info.LatestVersion,
|
||||||
"message": "更新已启动,服务会在完成后自动恢复",
|
"message": "更新已启动,服务会在完成后自动恢复",
|
||||||
})
|
})
|
||||||
|
a.scheduleUpdateService(info.CurrentVersion, info.LatestVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
||||||
@@ -175,7 +170,7 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.config().UpdateServiceToken))
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 10 * time.Minute,
|
||||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
@@ -192,6 +187,18 @@ func (a *App) triggerUpdateService(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) scheduleUpdateService(currentVersion, targetVersion string) {
|
||||||
|
go func() {
|
||||||
|
// Let the accepted response reach the browser before Watchtower replaces this container.
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if err := a.triggerUpdateService(ctx); err != nil {
|
||||||
|
a.log.Error("run scheduled system update", "error", err, "from", currentVersion, "to", targetVersion)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
|
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
|
||||||
backupDir := filepath.Join(a.config().DataDir, "backups")
|
backupDir := filepath.Join(a.config().DataDir, "backups")
|
||||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSystemVersionAndUpdate(t *testing.T) {
|
func TestSystemVersionAndUpdate(t *testing.T) {
|
||||||
@@ -22,6 +24,11 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
|||||||
defer releaseServer.Close()
|
defer releaseServer.Close()
|
||||||
|
|
||||||
var updateRequests atomic.Int32
|
var updateRequests atomic.Int32
|
||||||
|
updateStarted := make(chan struct{}, 1)
|
||||||
|
releaseUpdate := make(chan struct{})
|
||||||
|
var releaseUpdateOnce sync.Once
|
||||||
|
releaseBlockedUpdate := func() { releaseUpdateOnce.Do(func() { close(releaseUpdate) }) }
|
||||||
|
defer releaseBlockedUpdate()
|
||||||
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
t.Errorf("update method = %s", r.Method)
|
t.Errorf("update method = %s", r.Method)
|
||||||
@@ -30,6 +37,8 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
|||||||
t.Errorf("authorization = %q", got)
|
t.Errorf("authorization = %q", got)
|
||||||
}
|
}
|
||||||
updateRequests.Add(1)
|
updateRequests.Add(1)
|
||||||
|
updateStarted <- struct{}{}
|
||||||
|
<-releaseUpdate
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
defer updateServer.Close()
|
defer updateServer.Close()
|
||||||
@@ -66,12 +75,44 @@ func TestSystemVersionAndUpdate(t *testing.T) {
|
|||||||
t.Fatalf("unexpected version response: %+v", version)
|
t.Fatalf("unexpected version response: %+v", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
var update map[string]any
|
type updateResponse struct {
|
||||||
if code := admin.do("POST", "/api/admin/system/update", nil, &update); code != http.StatusAccepted {
|
code int
|
||||||
t.Fatalf("update code=%d response=%v", code, update)
|
err error
|
||||||
}
|
}
|
||||||
if updateRequests.Load() != 1 {
|
response := make(chan updateResponse, 1)
|
||||||
t.Fatalf("update requests=%d", updateRequests.Load())
|
go func() {
|
||||||
|
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/admin/system/update", nil)
|
||||||
|
if err != nil {
|
||||||
|
response <- updateResponse{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.AddCookie(admin.cookie)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
response <- updateResponse{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
response <- updateResponse{code: resp.StatusCode}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case result := <-response:
|
||||||
|
if result.err != nil || result.code != http.StatusAccepted {
|
||||||
|
t.Fatalf("update response=%+v", result)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
releaseBlockedUpdate()
|
||||||
|
t.Fatal("update response waited for container replacement")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-updateStarted:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("scheduled update request did not start")
|
||||||
|
}
|
||||||
|
releaseBlockedUpdate()
|
||||||
|
if got := updateRequests.Load(); got != 1 {
|
||||||
|
t.Fatalf("update requests=%d", got)
|
||||||
}
|
}
|
||||||
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
|
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
|
||||||
if err != nil || len(backups) != 1 {
|
if err != nil || len(backups) != 1 {
|
||||||
|
|||||||
@@ -27,7 +27,19 @@ export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "s
|
|||||||
const update = useMutation({
|
const update = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
setUpdatePhase("starting")
|
setUpdatePhase("starting")
|
||||||
const result = await api.updateSystem()
|
const targetVersion = version.data?.latestVersion
|
||||||
|
let result: Awaited<ReturnType<typeof api.updateSystem>>
|
||||||
|
try {
|
||||||
|
result = await api.updateSystem()
|
||||||
|
} catch (error) {
|
||||||
|
if (!targetVersion || !isUpdateConnectionInterruption(error)) throw error
|
||||||
|
result = {
|
||||||
|
ok: true,
|
||||||
|
currentVersion,
|
||||||
|
targetVersion,
|
||||||
|
message: "更新请求已发送,正在等待服务恢复",
|
||||||
|
}
|
||||||
|
}
|
||||||
setUpdatePhase("restarting")
|
setUpdatePhase("restarting")
|
||||||
await waitForUpdatedService(result.targetVersion)
|
await waitForUpdatedService(result.targetVersion)
|
||||||
return result
|
return result
|
||||||
@@ -176,3 +188,8 @@ async function waitForUpdatedService(targetVersion: string) {
|
|||||||
function delay(ms: number) {
|
function delay(ms: number) {
|
||||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUpdateConnectionInterruption(error: unknown) {
|
||||||
|
if (!(error instanceof Error)) return false
|
||||||
|
return /(?:502|503|504|网络请求失败|请求超时|failed to fetch|networkerror)/i.test(error.message)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user