系统配置页新增「版本与更新」卡片,顶栏版本号旁在有新版时亮出提示胶囊。 更新链路:下载发布包 → 比对 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/;更新即重启会中断运行中的任务。
176 lines
6.2 KiB
Go
176 lines
6.2 KiB
Go
// 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")
|
||
}
|