ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
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")
|
||
}
|