Files
Autumn-27 44156a0a4c 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/;更新即重启会中断运行中的任务。
2026-09-11 12:39:59 -04:00

176 lines
6.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package selfupdate implements ARTEX 的页面一键更新:从 GitHub Release 拉取新版
// 二进制、校验、暂存,并在下次启动时原子换装。
//
// 整体分工(见 start.sh / start.bat):
//
// 启动脚本 = 傻瓜守护循环,只负责"进程退出后按退出码决定是否再拉起"
// 本包 = 全部易错逻辑(下载 / SHA256 校验 / 冒烟 / 换装 / 失败回滚)
//
// 之所以把换装放在 Go 而不是脚本里,是因为 sha256 校验和冒烟测试在 sh 和 bat 上
// 要写两套(sha256sum / shasum / certutil),而这恰恰是最不能出错的一环——换上一个
// 跑不起来的二进制,守护进程会忠实地反复拉起它,用户只能上机器手工救。
//
// 一次完整升级经过三次进程启动:
//
// ① 旧版 server 收到 /api/update/apply → 下载校验 → 暂存 artex.new → exit 75
// ② 脚本重新拉起旧版 → Bootstrap 发现 artex.new → 校验+冒烟 → 换装 → exit 75
// ③ 脚本重新拉起,此时已是新版 → Bootstrap 记一次尝试 → 启动成功后清除标记
//
// 任何一步失败都退回旧版:② 校验不过就删掉暂存件继续跑旧版;③ 连续 3 次没活到
// 清除标记(起不来就崩)则自动把 artex.old 换回去。
package selfupdate
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// ExitRestart 是"请守护进程重新拉起我"的退出码(EX_TEMPFAIL)。启动脚本看到它
// 就立刻重跑,不计入崩溃退避。0 表示用户正常停止(脚本退出循环),其余均视为崩溃。
const ExitRestart = 75
// maxAttempts 是换装后允许的启动尝试次数。新版每次启动都会把计数 +1,活过
// settleDelay 则清除标记;连崩 maxAttempts 次说明新版根本起不来,自动回滚。
const maxAttempts = 3
// Paths 是一次升级涉及的全部文件,统一挂在**可执行文件所在目录**下。
// 刻意不用 CWD:服务化运行时工作目录可能是 / 或任意路径,用 CWD 会让暂存件落到
// 别处,换装逻辑直接失效。
type Paths struct {
Dir string // 可执行文件所在目录
Current string // 当前运行的二进制 artex / artex.exe
New string // 暂存的新版本 artex.new / artex.new.exe
Sum string // 新版本的 sha256(hex) artex.new.sha256 / artex.new.exe.sha256
Old string // 换装前备份的旧版本 artex.old / artex.old.exe
Marker string // 升级状态标记 artex.upgrade.json
}
// ResolvePaths 按当前可执行文件推导全部升级路径。
//
// Windows 上 .new/.old 也必须带 .exe 后缀,否则冒烟测试和换装后的执行都会失败,
// 所以先把后缀摘掉再拼,两个平台的命名才对称。
func ResolvePaths() (Paths, error) {
exe, err := os.Executable()
if err != nil {
return Paths{}, fmt.Errorf("定位可执行文件: %w", err)
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
dir := filepath.Dir(exe)
name := filepath.Base(exe)
ext := filepath.Ext(name) // Windows 上是 ".exe",Unix 上通常为空
stem := strings.TrimSuffix(name, ext)
join := func(suffix string) string { return filepath.Join(dir, stem+suffix+ext) }
return Paths{
Dir: dir,
Current: exe,
New: join(".new"),
Sum: join(".new") + ".sha256",
Old: join(".old"),
Marker: filepath.Join(dir, stem+".upgrade.json"),
}, nil
}
// marker 记录一次换装的进度,用来在新版起不来时触发自动回滚。
type marker struct {
From string `json:"from"` // 升级前的版本
To string `json:"to"` // 目标版本
Attempts int `json:"attempts"` // 换装后已尝试启动的次数
StagedAt int64 `json:"staged_at"`
}
func readMarker(path string) (marker, bool) {
b, err := os.ReadFile(path)
if err != nil {
return marker{}, false
}
var m marker
if json.Unmarshal(b, &m) != nil {
return marker{}, false
}
return m, true
}
func writeMarker(path string, m marker) error {
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
// cleanStaged 清掉暂存件。换装成功、校验失败、用户取消都走它,避免残留的
// artex.new 在下次启动时被重新尝试。
func cleanStaged(p Paths) {
_ = os.Remove(p.New)
_ = os.Remove(p.Sum)
}
// CompareVersions 比较两个版本号,返回 -1/0/1(a<b / a==b / a>b)。
// ok=false 表示至少一边不是可比较的版本号(例如本地开发构建的 "dev" 或
// git describe 产出的 "0.3.7-2-gabc1234-dirty"),此时调用方应禁用一键更新,
// 否则会把开发中的构建"升级"成正式版、覆盖掉未提交的改动。
func CompareVersions(a, b string) (int, bool) {
av, aok := parseVersion(a)
bv, bok := parseVersion(b)
if !aok || !bok {
return 0, false
}
for i := range 3 {
if av[i] != bv[i] {
if av[i] < bv[i] {
return -1, true
}
return 1, true
}
}
return 0, true
}
// parseVersion 解析 "v0.3.7" / "0.3.7" 形式的版本号为 [3]int。
//
// 只接受纯净的三段式:build.sh 在非 tag 构建时用 git describe 产出
// "0.3.7-2-gabc1234" 这类带后缀的版本,它们必须被判为不可比较,而不是被当成
// 0.3.7 —— 否则开发构建会被误判为"已是最新"或被正式版覆盖。
func parseVersion(s string) ([3]int, bool) {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "v")
if s == "" {
return [3]int{}, false
}
parts := strings.Split(s, ".")
if len(parts) != 3 {
return [3]int{}, false
}
var out [3]int
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return [3]int{}, false
}
out[i] = n
}
return out, true
}
// InDocker 报告进程是否跑在容器里。Docker 下换装写的是容器可写层,
// `docker compose up -d` 重建容器会退回镜像自带的版本——这是预期行为
// (那时用户本来就在拉新镜像),但前端要能据此把话说清楚。
func InDocker() bool {
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
b, err := os.ReadFile("/proc/1/cgroup")
if err != nil {
return false
}
s := string(b)
return strings.Contains(s, "docker") || strings.Contains(s, "containerd")
}