First Commit
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
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
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in New Issue
Block a user