mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
系统配置页新增「版本与更新」卡片,顶栏版本号旁在有新版时亮出提示胶囊。 更新链路:下载发布包 → 比对 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/;更新即重启会中断运行中的任务。
300 lines
10 KiB
Go
300 lines
10 KiB
Go
package selfupdate
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"os"
|
||
"os/exec"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// smokeEnv 让被冒烟测试拉起的子进程直接跳过 Bootstrap。
|
||
//
|
||
// 严格来说不加也不会出事:子进程的 os.Executable() 是 artex.new,推导出来的
|
||
// 全部路径都带 .new 前缀,碰不到真正的升级文件。但依赖这种巧合太脆弱,
|
||
// 显式短路一目了然,也省掉子进程一次无谓的磁盘探测。
|
||
const smokeEnv = "ARTEX_SELFUPDATE_SMOKE"
|
||
|
||
// Action 是 Bootstrap 给 main 的指令。
|
||
type Action int
|
||
|
||
const (
|
||
// Continue:照常启动 server。
|
||
Continue Action = iota
|
||
// Restart:立刻以 ExitRestart 退出,让守护脚本重新拉起。
|
||
Restart
|
||
)
|
||
|
||
// State 描述本次启动时的升级状态,供 /api/update/check 如实告诉前端
|
||
// "上一次升级是成功了还是被回滚了"。
|
||
type State struct {
|
||
Pending bool // 换装后尚未确认稳定
|
||
RolledBack bool // 本次启动刚刚执行过自动回滚
|
||
FailedStage bool // 暂存件校验/冒烟未通过,已丢弃
|
||
Detail string // 面向用户的一句话说明
|
||
}
|
||
|
||
// Bootstrap 在 main 的最开头运行,必须在任何监听端口、打开数据库之前调用。
|
||
//
|
||
// 三种局面:
|
||
//
|
||
// ① 存在暂存件 artex.new → 校验 + 冒烟,通过则换装并要求重启;不通过则丢弃继续跑旧版
|
||
// ② 只剩标记文件 → 说明刚换装完,累计一次尝试;连续失败够多次则回滚
|
||
// ③ 什么都没有 → 正常启动
|
||
func Bootstrap() (Action, State) {
|
||
if os.Getenv(smokeEnv) != "" {
|
||
return Continue, State{}
|
||
}
|
||
p, err := ResolvePaths()
|
||
if err != nil {
|
||
log.Printf("[update] 跳过自举:%v", err)
|
||
return Continue, State{}
|
||
}
|
||
|
||
if _, err := os.Stat(p.New); err == nil {
|
||
return applyStaged(p)
|
||
}
|
||
|
||
m, ok := readMarker(p.Marker)
|
||
if !ok {
|
||
return Continue, State{}
|
||
}
|
||
return confirmOrRollback(p, m)
|
||
}
|
||
|
||
// applyStaged 处理"存在暂存件"的局面:校验通过就换装,失败就丢弃。
|
||
//
|
||
// 这里是整个升级链路唯一会覆盖可执行文件的地方,也是最后一道闸门——冒烟测试挡掉
|
||
// 下载损坏、架构选错、动态链接缺失这类问题。一旦放行一个跑不起来的二进制,
|
||
// 守护脚本会不知疲倦地反复拉起它,而 Go 代码根本没机会运行,自动回滚也就无从谈起。
|
||
func applyStaged(p Paths) (Action, State) {
|
||
m, _ := readMarker(p.Marker)
|
||
|
||
if err := verifyStaged(p); err != nil {
|
||
log.Printf("[update] 暂存的新版本未通过校验,已丢弃,继续运行当前版本:%v", err)
|
||
cleanStaged(p)
|
||
_ = os.Remove(p.Marker)
|
||
return Continue, State{FailedStage: true, Detail: "新版本校验失败,已丢弃:" + err.Error()}
|
||
}
|
||
|
||
if err := swap(p); err != nil {
|
||
log.Printf("[update] 换装失败,继续运行当前版本:%v", err)
|
||
cleanStaged(p)
|
||
_ = os.Remove(p.Marker)
|
||
return Continue, State{FailedStage: true, Detail: "换装失败:" + err.Error()}
|
||
}
|
||
|
||
// 换装成功。保留标记,交给下一次启动(跑的就是新版)确认是否稳定。
|
||
m.Attempts = 0
|
||
if m.StagedAt == 0 {
|
||
m.StagedAt = time.Now().Unix()
|
||
}
|
||
if err := writeMarker(p.Marker, m); err != nil {
|
||
log.Printf("[update] 写升级标记失败(失去自动回滚能力):%v", err)
|
||
}
|
||
log.Printf("[update] 已换装到 %s,退出以重启(exit %d)", orUnknown(m.To), ExitRestart)
|
||
return Restart, State{Pending: true}
|
||
}
|
||
|
||
// confirmOrRollback 处理"换装后的启动":累计尝试次数,超限则把旧版换回来。
|
||
//
|
||
// 计数只在 Go 代码跑起来后才递增,所以它覆盖的是"能执行但初始化时崩溃"
|
||
// (配置不兼容、端口被占、DB 迁移炸了)这类故障;"根本无法 exec" 由换装前的
|
||
// 冒烟测试挡住,两者合起来才是完整的。
|
||
func confirmOrRollback(p Paths, m marker) (Action, State) {
|
||
m.Attempts++
|
||
if m.Attempts > maxAttempts {
|
||
if err := rollback(p); err != nil {
|
||
// 回滚都失败了就别再重启了,否则会陷入无限重启。清掉标记,
|
||
// 让进程按当前状态起——起不来的话用户至少能在日志里看到原因。
|
||
log.Printf("[update] 新版本连续 %d 次启动失败,且回滚失败:%v", maxAttempts, err)
|
||
_ = os.Remove(p.Marker)
|
||
return Continue, State{Detail: "新版本启动失败且回滚失败:" + err.Error()}
|
||
}
|
||
log.Printf("[update] 新版本连续 %d 次启动失败,已回滚到 %s,退出以重启(exit %d)",
|
||
maxAttempts, orUnknown(m.From), ExitRestart)
|
||
_ = os.Remove(p.Marker)
|
||
return Restart, State{RolledBack: true, Detail: fmt.Sprintf("新版本启动失败,已回滚到 %s", orUnknown(m.From))}
|
||
}
|
||
if err := writeMarker(p.Marker, m); err != nil {
|
||
log.Printf("[update] 更新升级标记失败:%v", err)
|
||
}
|
||
log.Printf("[update] 新版本启动中(第 %d/%d 次尝试),稳定运行后将确认升级",
|
||
m.Attempts, maxAttempts)
|
||
return Continue, State{Pending: true}
|
||
}
|
||
|
||
// Settle 确认新版本已稳定运行,清除升级标记。
|
||
//
|
||
// 由 main 在 HTTP 监听起来之后延迟调用:活过这段时间才算数,否则标记留在原地,
|
||
// 下次启动继续累计尝试次数,直到触发回滚。
|
||
func Settle() {
|
||
p, err := ResolvePaths()
|
||
if err != nil {
|
||
return
|
||
}
|
||
settle(p)
|
||
}
|
||
|
||
func settle(p Paths) {
|
||
if _, ok := readMarker(p.Marker); !ok {
|
||
return // 不是升级后的启动,无事可做
|
||
}
|
||
if err := os.Remove(p.Marker); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||
log.Printf("[update] 清除升级标记失败:%v", err)
|
||
return
|
||
}
|
||
log.Printf("[update] 新版本运行稳定,升级完成(上一版本保留为 %s)", p.Old)
|
||
}
|
||
|
||
// SettleDelay 是判定"新版本活下来了"所需的运行时长。
|
||
const SettleDelay = 30 * time.Second
|
||
|
||
// verifyStaged 校验暂存件:先比对 SHA256,再真正把它拉起来跑一次。
|
||
func verifyStaged(p Paths) error {
|
||
want, err := os.ReadFile(p.Sum)
|
||
if err != nil {
|
||
return fmt.Errorf("读取校验和: %w", err)
|
||
}
|
||
got, err := fileSHA256(p.New)
|
||
if err != nil {
|
||
return fmt.Errorf("计算校验和: %w", err)
|
||
}
|
||
if !strings.EqualFold(strings.TrimSpace(string(want)), got) {
|
||
return errors.New("SHA256 不匹配(下载损坏或被篡改)")
|
||
}
|
||
return smokeTest(p.New)
|
||
}
|
||
|
||
// smokeTest 用 -h 拉起新二进制,确认它在当前系统上真的能执行。
|
||
// 这能挡掉下载截断、架构选错(exec format error)、缺依赖等一大类问题。
|
||
func smokeTest(bin string) error {
|
||
if err := os.Chmod(bin, 0o755); err != nil {
|
||
return fmt.Errorf("赋予执行权限: %w", err)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
cmd := exec.CommandContext(ctx, bin, "-h")
|
||
cmd.Env = append(os.Environ(), smokeEnv+"=1")
|
||
out, err := cmd.CombinedOutput()
|
||
if ctx.Err() != nil {
|
||
return errors.New("冒烟测试超时(新二进制无响应)")
|
||
}
|
||
if err != nil {
|
||
snippet := strings.TrimSpace(string(out))
|
||
if len(snippet) > 300 {
|
||
snippet = snippet[:300] + "…"
|
||
}
|
||
return fmt.Errorf("冒烟测试失败: %v: %s", err, snippet)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// swap 把当前二进制换成暂存的新版本。
|
||
//
|
||
// Unix 和 Windows 都允许 rename 一个正在运行的可执行文件(Windows 禁止的是删除和
|
||
// 覆盖,rename 不在其列),所以这里不需要分平台,也不需要先停掉自己。
|
||
func swap(p Paths) error {
|
||
// Windows 的 rename 不会覆盖已存在的目标,上一轮升级留下的 .old 必须先清掉。
|
||
if err := os.Remove(p.Old); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||
return fmt.Errorf("清理旧备份 %s: %w", p.Old, err)
|
||
}
|
||
if err := os.Rename(p.Current, p.Old); err != nil {
|
||
return fmt.Errorf("备份当前版本: %w", err)
|
||
}
|
||
if err := os.Rename(p.New, p.Current); err != nil {
|
||
// 换装失败但当前版本已经被挪走了,必须原样放回去,否则下次启动没有可执行文件。
|
||
if rerr := os.Rename(p.Old, p.Current); rerr != nil {
|
||
return fmt.Errorf("装入新版本失败(%v),且恢复当前版本失败: %w", err, rerr)
|
||
}
|
||
return fmt.Errorf("装入新版本: %w", err)
|
||
}
|
||
_ = os.Remove(p.Sum)
|
||
return nil
|
||
}
|
||
|
||
// rollback 把 swap 备份的旧版本换回来。
|
||
func rollback(p Paths) error {
|
||
if _, err := os.Stat(p.Old); err != nil {
|
||
return fmt.Errorf("没有可回滚的备份 %s: %w", p.Old, err)
|
||
}
|
||
// 把起不来的新版挪到 .failed 留作排查,而不是直接删掉。
|
||
failed := p.Current + ".failed"
|
||
_ = os.Remove(failed)
|
||
if err := os.Rename(p.Current, failed); err != nil {
|
||
return fmt.Errorf("移走失败的版本: %w", err)
|
||
}
|
||
if err := os.Rename(p.Old, p.Current); err != nil {
|
||
return fmt.Errorf("恢复旧版本: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Rollback 是 /api/update/rollback 的实现:主动退回上一版本。
|
||
// 只做换装,重启同样交给守护脚本(调用方随后以 ExitRestart 退出)。
|
||
func Rollback() error {
|
||
p, err := ResolvePaths()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, err := os.Stat(p.Old); err != nil {
|
||
return errors.New("没有可回滚的上一版本(" + p.Old + " 不存在)")
|
||
}
|
||
cleanStaged(p)
|
||
if err := smokeTest(p.Old); err != nil {
|
||
return fmt.Errorf("上一版本无法执行,拒绝回滚: %w", err)
|
||
}
|
||
// 交换当前与备份:回滚之后还能再滚回来。
|
||
tmp := p.Current + ".swap"
|
||
_ = os.Remove(tmp)
|
||
if err := os.Rename(p.Current, tmp); err != nil {
|
||
return fmt.Errorf("移走当前版本: %w", err)
|
||
}
|
||
if err := os.Rename(p.Old, p.Current); err != nil {
|
||
_ = os.Rename(tmp, p.Current)
|
||
return fmt.Errorf("装入上一版本: %w", err)
|
||
}
|
||
if err := os.Rename(tmp, p.Old); err != nil {
|
||
log.Printf("[update] 回滚后整理备份失败(不影响运行):%v", err)
|
||
}
|
||
_ = os.Remove(p.Marker)
|
||
return nil
|
||
}
|
||
|
||
// HasBackup 报告是否存在可回滚的上一版本,供前端决定要不要显示回滚按钮。
|
||
func HasBackup() bool {
|
||
p, err := ResolvePaths()
|
||
if err != nil {
|
||
return false
|
||
}
|
||
_, err = os.Stat(p.Old)
|
||
return err == nil
|
||
}
|
||
|
||
func fileSHA256(path string) (string, error) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer f.Close()
|
||
h := sha256.New()
|
||
if _, err := io.Copy(h, f); err != nil {
|
||
return "", err
|
||
}
|
||
return hex.EncodeToString(h.Sum(nil)), nil
|
||
}
|
||
|
||
func orUnknown(s string) string {
|
||
if strings.TrimSpace(s) == "" {
|
||
return "未知版本"
|
||
}
|
||
return s
|
||
}
|