feat(update): 页面一键更新 + 守护启动脚本

系统配置页新增「版本与更新」卡片,顶栏版本号旁在有新版时亮出提示胶囊。
更新链路:下载发布包 → 比对 SHA256SUMS → -h 冒烟测试 → 暂存 artex.new
→ 以退出码 75 退出 → 守护脚本重新拉起并完成换装,页面轮询 /api/health
等版本号变化后自动刷新。

新增 start.sh / start.bat 作为正式启动入口,只按退出码决定是否重新拉起
(0 停止 / 75 应用新版 / 其余崩溃退避)。校验、冒烟、换装、回滚全部收在
selfupdate 包里而不写进脚本:这些逻辑在 sh 和 bat 上要写两套,而它们恰恰
最不能出错——换上跑不起来的二进制后守护进程会反复拉起它,只能上机器手工救。
Docker 的 ENTRYPOINT 一并改为该脚本,并由它转发 SIGTERM,否则 docker stop
只发给 PID 1,artex 做不了优雅关闭会被 SIGKILL 硬杀。

失败保护两道闸门互补:无法 exec 由换装前的冒烟挡住,能执行但初始化崩溃
由启动计数兜住(连续 3 次失败自动回滚 artex.old,坏的留作 artex.failed)。

GitHub 查询结果后端缓存 30 分钟(失败 2 分钟)。未认证 API 是每 IP 每小时
60 次,且这 60 次是服务器 IP 上所有用户共用的,顶栏每次整页加载都查一次,
不缓存会很快耗光配额导致真想更新时反而查不动。手动「检查更新」带 force=1
绕过缓存。

安全:只认 GitHub 域名且强制 HTTPS(逐跳校验重定向),发布源写死不可配置;
升级文件挂在可执行文件所在目录而非 CWD(服务化运行时 CWD 可能是 /);
开发构建版本号不可比较,禁用一键更新。

走完整 zip 而非裸二进制,因为现有 Release 的 SHA256SUMS 只覆盖 zip,
这样无需改 CI,也兼容已发布的历史版本。

已知限制:Docker 下只换程序不换镜像(工具链不同步、重建容器会退回镜像
版本);不同步发布包里的 skills/;更新即重启会中断运行中的任务。
This commit is contained in:
Autumn-27 committed 2026-09-11 12:39:59 -04:00
1 parent 557317491b
commit 44156a0a4c
22 files changed
+2619 -16

No files matched your search

+7
View File
@@ -750,6 +750,13 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/logs/history", s.getLogsHistory)
mux.HandleFunc("GET /api/logs/stream", s.streamLogs)
// 页面一键更新。走的是默认的 JWT 鉴权(auth.go 只放行 /api/auth/* 和
// /api/health),所以这几个改动程序自身的接口天然需要登录。
mux.HandleFunc("GET /api/update/check", s.updateCheck)
mux.HandleFunc("POST /api/update/apply", s.updateApply)
mux.HandleFunc("POST /api/update/rollback", s.updateRollback)
mux.HandleFunc("GET /api/update/stream", s.updateStream)
mux.HandleFunc("GET /api/tasks", s.listTasks)
mux.HandleFunc("POST /api/tasks", s.createTask)
mux.HandleFunc("GET /api/task-categories", s.pgListTaskCategories)
+373
View File
@@ -0,0 +1,373 @@
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"runtime"
"sync"
"time"
"github.com/Autumn-27/artex/selfupdate"
)
// 页面一键更新的 HTTP 面。真正的下载/校验/换装逻辑全在 selfupdate 包里,
// 这里只负责鉴权边界、并发互斥、进度广播,以及把"该退出了"告诉 main。
//
// 重启不由本进程完成:暂存好新版本后进程以 selfupdate.ExitRestart 退出,
// 由守护脚本(start.sh / start.bat,Docker 下是 ENTRYPOINT)重新拉起。
// restartCh 在升级就绪或回滚完成后关闭,main 收到后以 ExitRestart 退出。
var (
restartOnce sync.Once
restartCh = make(chan struct{})
)
// RestartRequested 返回一个在"请退出并让守护进程重新拉起我"时关闭的 channel。
func RestartRequested() <-chan struct{} { return restartCh }
func requestRestart() { restartOnce.Do(func() { close(restartCh) }) }
// bootState 是本次启动时 selfupdate.Bootstrap 的结论(升级成功 / 刚回滚 /
// 暂存件被丢弃),由 main 注入,供 /api/update/check 如实告诉前端上一次升级的下场。
var (
bootStateMu sync.Mutex
bootState selfupdate.State
)
// SetBootUpdateState 由 main 在启动时调用一次。
func SetBootUpdateState(st selfupdate.State) {
bootStateMu.Lock()
defer bootStateMu.Unlock()
bootState = st
}
func bootUpdateState() selfupdate.State {
bootStateMu.Lock()
defer bootStateMu.Unlock()
return bootState
}
// releaseCache 缓存 GitHub 的最新版本查询结果。
//
// 顶栏的"有新版本"提示会在每次整页加载时查一次,而未认证的 GitHub API 是
// 每 IP 每小时 60 次——不缓存的话,多开几个标签页或刷几次页面就把配额耗光了,
// 之后真想更新时反而查不动。用户显式点"检查更新"时可以 force 绕过缓存。
type releaseCache struct {
mu sync.Mutex
rel *selfupdate.Release
err error
at time.Time
// fetch 是取数函数,仅为测试留的注入点;为 nil 时走真正的 GitHub 查询。
fetch func(context.Context, *http.Client) (*selfupdate.Release, error)
}
const (
releaseTTL = 30 * time.Minute
// 失败结果也缓存一小会儿,否则 GitHub 不可达时每次页面加载都要干等一次超时;
// 但 TTL 要短,网络恢复后很快就能自己好。
releaseErrTTL = 2 * time.Minute
// 查询用的超时。NewClient 的 30 分钟超时是给下载整包用的,查版本不能等那么久。
releaseTimeout = 20 * time.Second
)
var relCache = &releaseCache{}
// get 返回最新 Release,命中缓存则不访问网络。
//
// 取数期间一直持有锁:并发请求会排队等同一次查询的结果,而不是各自去打 GitHub
// (页面刚加载时多个标签页同时来查,正是最容易触发限流的时刻)。
func (c *releaseCache) get(ctx context.Context, client *http.Client, force bool) (*selfupdate.Release, error) {
c.mu.Lock()
defer c.mu.Unlock()
if !force {
ttl := releaseTTL
if c.err != nil {
ttl = releaseErrTTL
}
if !c.at.IsZero() && time.Since(c.at) < ttl {
return c.rel, c.err
}
}
fetch := c.fetch
if fetch == nil {
fetch = selfupdate.FetchLatest
}
ctx, cancel := context.WithTimeout(ctx, releaseTimeout)
defer cancel()
rel, err := fetch(ctx, client)
// 请求被取消(用户关了标签页)不代表 GitHub 有问题,别把它写进缓存,
// 否则下一个访客会拿到一条莫名其妙的"已取消"错误。
if err != nil && ctx.Err() != nil && errors.Is(ctx.Err(), context.Canceled) {
return c.rel, err
}
c.rel, c.err, c.at = rel, err, time.Now()
return rel, err
}
// updateProgress 是推给前端的一条进度。
type updateProgress struct {
Phase selfupdate.Phase `json:"phase"`
Percent int `json:"percent"` // 仅下载阶段有意义;其余为 -1
Message string `json:"message"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}
// updateHub 持有一次升级的进度并广播给 SSE 订阅者。
//
// running 同时充当互斥:升级期间再次 POST /api/update/apply 直接 409,
// 避免两个 goroutine 同时往同一个 artex.new 写。
type updateHub struct {
mu sync.Mutex
running bool
cur updateProgress
subs map[chan updateProgress]struct{}
}
var updHub = &updateHub{
cur: updateProgress{Phase: selfupdate.PhaseIdle, Percent: -1},
subs: map[chan updateProgress]struct{}{},
}
// begin 抢占升级权限,已在进行中则返回 false。
func (h *updateHub) begin(version string) bool {
h.mu.Lock()
defer h.mu.Unlock()
if h.running {
return false
}
h.running = true
h.cur = updateProgress{Phase: selfupdate.PhaseDownload, Percent: 0, Message: "准备中…", Version: version}
h.fanout(h.cur)
return true
}
// finish 结束一次升级。err 为 nil 表示暂存成功,等待重启。
func (h *updateHub) finish(err error) {
h.mu.Lock()
defer h.mu.Unlock()
h.running = false
if err != nil {
h.cur = updateProgress{Phase: selfupdate.PhaseFailed, Percent: -1, Message: "更新失败", Error: err.Error(), Version: h.cur.Version}
} else {
h.cur = updateProgress{Phase: selfupdate.PhaseStaged, Percent: 100, Message: "新版本已就绪,正在重启…", Version: h.cur.Version}
}
h.fanout(h.cur)
}
func (h *updateHub) publish(ph selfupdate.Phase, pct int, msg string) {
h.mu.Lock()
defer h.mu.Unlock()
h.cur = updateProgress{Phase: ph, Percent: pct, Message: msg, Version: h.cur.Version}
h.fanout(h.cur)
}
// fanout 必须在持有 h.mu 时调用。订阅者 channel 是有缓冲的,满了就丢——
// 进度是可丢弃的瞬时信息,绝不能让一个卡住的 SSE 连接阻塞升级本身。
func (h *updateHub) fanout(p updateProgress) {
for ch := range h.subs {
select {
case ch <- p:
default:
}
}
}
func (h *updateHub) snapshot() (updateProgress, bool) {
h.mu.Lock()
defer h.mu.Unlock()
return h.cur, h.running
}
func (h *updateHub) subscribe() (<-chan updateProgress, func()) {
ch := make(chan updateProgress, 64)
h.mu.Lock()
h.subs[ch] = struct{}{}
h.mu.Unlock()
var once sync.Once
return ch, func() {
once.Do(func() {
h.mu.Lock()
delete(h.subs, ch)
h.mu.Unlock()
close(ch)
})
}
}
// updateCheck 查询 GitHub 上的最新正式版并与当前版本比较。
//
// 前端也会直连 api.github.com(GitHub 的 CORS 是 *),但**以本接口为准**:
// 下载是后端做的,只有后端能访问 GitHub 才谈得上更新。浏览器能连、服务器连不上
// 的情况很常见(服务器在内网、或代理只配在浏览器上),那时点更新必然失败,
// 不如在检查这一步就如实报错。
func (s *Server) updateCheck(w http.ResponseWriter, r *http.Request) {
current := BuildVersion
mode := "binary"
if selfupdate.InDocker() {
mode = "docker"
}
boot := bootUpdateState()
out := map[string]any{
"current": current,
"mode": mode,
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"has_backup": selfupdate.HasBackup(),
"repo": selfupdate.Repo,
"boot_notice": boot.Detail,
"rolled_back": boot.RolledBack,
}
// 顶栏提示走缓存(默认);用户点"检查更新"时带 force=1 强制回源。
force := r.URL.Query().Get("force") != ""
client := selfupdate.NewClient(s.m.GlobalProxy())
rel, err := relCache.get(r.Context(), client, force)
if err != nil {
out["error"] = err.Error()
writeJSON(w, 200, out)
return
}
latest := rel.TagName
out["latest"] = latest
out["notes"] = rel.Body
out["html_url"] = rel.HTMLURL
if !rel.PublishedAt.IsZero() {
out["published_at"] = rel.PublishedAt.Format(time.RFC3339)
}
asset := selfupdate.AssetName(latest, runtime.GOOS, runtime.GOARCH)
out["asset"] = asset
if a, ok := rel.FindAsset(asset); ok {
out["asset_available"] = true
out["size"] = a.Size
} else {
out["asset_available"] = false
}
cmp, comparable := selfupdate.CompareVersions(current, latest)
out["comparable"] = comparable
out["has_update"] = comparable && cmp < 0
if !comparable {
// 开发构建(dev / git describe 带后缀)没有可比较的版本号。放行只会
// 用正式版覆盖掉本地正在调试的二进制,所以直接不给更新。
out["reason"] = fmt.Sprintf("当前版本 %q 不是正式发布版本,已禁用一键更新", current)
}
writeJSON(w, 200, out)
}
// updateApply 下载并暂存新版本,完成后让进程退出交给守护脚本重启。
//
// 立刻返回 202,实际工作在后台 goroutine 上跑:整包下载可能要几分钟,
// 挂在请求上会被反代超时掐断。进度走 /api/update/stream。
func (s *Server) updateApply(w http.ResponseWriter, r *http.Request) {
current := BuildVersion
// 走缓存:确保装上的就是用户在界面上看到并确认的那个版本。
client := selfupdate.NewClient(s.m.GlobalProxy())
rel, err := relCache.get(r.Context(), client, false)
if err != nil {
writeErr(w, 502, err.Error())
return
}
cmp, comparable := selfupdate.CompareVersions(current, rel.TagName)
if !comparable {
writeErr(w, 400, fmt.Sprintf("当前版本 %q 不是正式发布版本,已禁用一键更新", current))
return
}
if cmp >= 0 {
writeErr(w, 400, fmt.Sprintf("当前已是最新版本 %s", current))
return
}
if !updHub.begin(rel.TagName) {
writeErr(w, 409, "已有一个更新正在进行中")
return
}
go func() {
// 刻意用 s.ctx 而不是请求的 ctx:HTTP 响应一返回请求就结束了,
// 挂在它上面下载会立刻被取消。
err := selfupdate.Stage(s.ctx, client, rel, current, func(ph selfupdate.Phase, pct int, msg string) {
updHub.publish(ph, pct, msg)
})
updHub.finish(err)
if err != nil {
log.Printf("[update] 更新失败:%v", err)
return
}
log.Printf("[update] %s → %s 已暂存,即将退出以完成换装", current, rel.TagName)
// 留一点时间把最后一条进度推给前端,再触发退出。
time.Sleep(1500 * time.Millisecond)
requestRestart()
}()
writeJSON(w, 202, map[string]any{"ok": true, "target": rel.TagName})
}
// updateRollback 主动退回上一版本(换装前备份的 artex.old)。
func (s *Server) updateRollback(w http.ResponseWriter, r *http.Request) {
if _, running := updHub.snapshot(); running {
writeErr(w, 409, "更新正在进行中,无法回滚")
return
}
if err := selfupdate.Rollback(); err != nil {
writeErr(w, 400, err.Error())
return
}
log.Printf("[update] 已手动回滚到上一版本,即将退出以完成切换")
writeJSON(w, 202, map[string]any{"ok": true})
go func() {
time.Sleep(500 * time.Millisecond)
requestRestart()
}()
}
// updateStream 以 SSE 推送更新进度。
func (s *Server) updateStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, 500, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
ch, unsub := updHub.subscribe()
defer unsub()
send := func(p updateProgress) {
b, _ := json.Marshal(p)
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
}
// 先补一条当前状态,页面刷新后能立刻看到进行中的升级。
cur, _ := updHub.snapshot()
send(cur)
ctx := r.Context()
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case p, ok := <-ch:
if !ok {
return
}
send(p)
case <-ping.C:
fmt.Fprint(w, ": ping\n\n")
flusher.Flush()
}
}
}
+155
View File
@@ -0,0 +1,155 @@
package server
import (
"context"
"errors"
"net/http"
"testing"
"time"
"github.com/Autumn-27/artex/selfupdate"
)
// releaseCache 是保护 GitHub 配额的那一层:未认证的 API 只有 60 次/小时/IP,
// 而顶栏的"有新版本"提示每次整页加载都会查一次。缓存一旦失效,用户多开几个
// 标签页就会把配额耗光,之后真想更新反而查不动。
func newTestCache(fetch func(context.Context, *http.Client) (*selfupdate.Release, error)) *releaseCache {
return &releaseCache{fetch: fetch}
}
func TestReleaseCacheServesFromCache(t *testing.T) {
calls := 0
c := newTestCache(func(context.Context, *http.Client) (*selfupdate.Release, error) {
calls++
return &selfupdate.Release{TagName: "v0.3.8"}, nil
})
for range 5 {
rel, err := c.get(t.Context(), nil, false)
if err != nil {
t.Fatalf("get: %v", err)
}
if rel.TagName != "v0.3.8" {
t.Fatalf("TagName = %q", rel.TagName)
}
}
if calls != 1 {
t.Errorf("5 次查询只应回源 1 次,实际 %d 次", calls)
}
}
func TestReleaseCacheForceBypasses(t *testing.T) {
calls := 0
c := newTestCache(func(context.Context, *http.Client) (*selfupdate.Release, error) {
calls++
return &selfupdate.Release{TagName: "v0.3.8"}, nil
})
if _, err := c.get(t.Context(), nil, false); err != nil {
t.Fatal(err)
}
// 用户点「检查更新」必须拿到实时结果,否则刚发布的版本要等缓存过期才看得见。
if _, err := c.get(t.Context(), nil, true); err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Errorf("force 应绕过缓存,期望回源 2 次,实际 %d 次", calls)
}
}
func TestReleaseCacheExpiresAfterTTL(t *testing.T) {
calls := 0
c := newTestCache(func(context.Context, *http.Client) (*selfupdate.Release, error) {
calls++
return &selfupdate.Release{TagName: "v0.3.8"}, nil
})
if _, err := c.get(t.Context(), nil, false); err != nil {
t.Fatal(err)
}
// 把落库时间往前拨到刚过期,模拟 TTL 到点。
c.at = time.Now().Add(-releaseTTL - time.Second)
if _, err := c.get(t.Context(), nil, false); err != nil {
t.Fatal(err)
}
if calls != 2 {
t.Errorf("TTL 过期后应重新回源,期望 2 次,实际 %d 次", calls)
}
}
func TestReleaseCacheUsesShorterTTLForErrors(t *testing.T) {
calls := 0
c := newTestCache(func(context.Context, *http.Client) (*selfupdate.Release, error) {
calls++
return nil, errors.New("github 不可达")
})
if _, err := c.get(t.Context(), nil, false); err == nil {
t.Fatal("期望返回错误")
}
// 失败结果也要缓存一会儿,否则 GitHub 不可达时每次页面加载都白等一次超时。
if _, err := c.get(t.Context(), nil, false); err == nil {
t.Fatal("期望返回错误")
}
if calls != 1 {
t.Errorf("错误应短时缓存,期望回源 1 次,实际 %d 次", calls)
}
// 但错误的 TTL 必须明显短于成功的,网络恢复后要能很快自愈。
if releaseErrTTL >= releaseTTL {
t.Fatalf("错误 TTL(%v) 必须短于成功 TTL(%v)", releaseErrTTL, releaseTTL)
}
c.at = time.Now().Add(-releaseErrTTL - time.Second)
if _, err := c.get(t.Context(), nil, false); err == nil {
t.Fatal("期望返回错误")
}
if calls != 2 {
t.Errorf("错误 TTL 过期后应重试,期望 2 次,实际 %d 次", calls)
}
}
func TestReleaseCacheDoesNotPoisonOnCallerCancel(t *testing.T) {
good := &selfupdate.Release{TagName: "v0.3.8"}
c := newTestCache(func(ctx context.Context, _ *http.Client) (*selfupdate.Release, error) {
return good, nil
})
if _, err := c.get(t.Context(), nil, false); err != nil {
t.Fatal(err)
}
// 访客关掉标签页会取消请求。那不代表 GitHub 有问题,绝不能把"已取消"
// 写进缓存——否则接下来 30 分钟内每个访客都会收到一条莫名其妙的错误。
c.fetch = func(ctx context.Context, _ *http.Client) (*selfupdate.Release, error) {
return nil, ctx.Err()
}
c.at = time.Now().Add(-releaseTTL - time.Second) // 让缓存过期,逼它回源
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := c.get(ctx, nil, false); err == nil {
t.Fatal("调用方已取消时应把错误透传给它")
}
// 关键不变量:被取消的那一次不留下任何痕迹——缓存里既没有"已取消"这个错误,
// 也还保着上一次的好结果。
if c.err != nil {
t.Fatalf("取消错误不应写进缓存,得到 %v", c.err)
}
if c.rel == nil || c.rel.TagName != "v0.3.8" {
t.Fatalf("缓存应保留上一次的好结果,得到 %+v", c.rel)
}
// 那次取消没换来任何新数据,所以下一个访客理应重新回源——而且能正常拿到结果,
// 不会被上一次的取消连累。
c.fetch = func(context.Context, *http.Client) (*selfupdate.Release, error) {
return good, nil
}
rel, err := c.get(t.Context(), nil, false)
if err != nil {
t.Fatalf("取消之后的正常请求不应报错: %v", err)
}
if rel == nil || rel.TagName != "v0.3.8" {
t.Fatalf("应拿到正常结果,得到 %+v", rel)
}
}