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

This commit is contained in:
dela
2026-10-09 08:38:16 +08:00
commit 0335d572de
756 changed files with 201663 additions and 0 deletions
+175
View File
@@ -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")
}