Files
ratatoskr-go/internal/update/update.go
Hermes 63668a0f69
All checks were successful
CI / test (push) Successful in 59s
CI / build-and-package (amd64, darwin) (push) Successful in 42s
CI / build-and-package (amd64, linux) (push) Successful in 40s
CI / build-and-package (amd64, windows) (push) Successful in 42s
CI / build-and-package (arm64, darwin) (push) Successful in 45s
CI / build-and-package (arm64, linux) (push) Successful in 41s
fix(update): убрать псевдо-версию latest, всё в commit-<sha7>
Gitea: при параллельных PUT матрицы (6 платформ) псевдо-версия 'latest'
переуказывается на каждый снимок, companion-файлы и бинарь разъезжаются
между версиями — checksum U4 ломался.

- CI: публикует бинарь+.version+.sha256 в  (commit-<sha7>), latest не используется
- Updater.ResolveLatest: новейшая commit-* версия из листинга /api/v1/packages/{owner}/generic/{package},
  берём макс id у которой есть бинарь текущей платформы
- Download/Verify/checksum читают ТУ ЖЕ version, а не latest
2026-08-16 13:05:37 +05:00

355 lines
14 KiB
Go
Raw 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 update — система автообновления бинаря ratatoskr.
//
// Источник: Gitea Packages (generic), хранилище `ratatoskr`. Каждая сборка
// публикуется в отдельную версию `commit-<sha7>` и содержит три файла:
// - <filename> сам бинарь
// - <filename>.version текстовый идентификатор версии (commit-<sha7>)
// - <filename>.sha256 hex-контрольная сумма бинаря
//
// Намеренно НЕ используется псевдо-версия `latest`: в Gitea при параллельных
// PUT (матрица CI: 6 платформ) она «переуказывается» на каждый снимок, и файлы
// одного релиза разъезжаются между разными версиями. Вместо этого новейшая
// версия определяется списком — GET /api/v1/packages/{owner}/generic/{package}
// отдаёт массив версий (id, version, created_at), берём новый `commit-*`.
//
// Модель обновления: «только по команде /update». Авто-режим (по расписанию
// и при старте) делает ТОЛЬКО Check и уведомляет владельца в чат — ничего не
// качает и не трогает работающий процесс.
package update
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// Updater инкапсулирует проверку, скачивание и замену бинаря.
type Updater struct {
// BaseURL — корень Gitea (например http://gitea.hal9000.home).
BaseURL string
// Owner — владелец пакета (в конфиге ratatoskr, обычно пользователь Git).
Owner string
// Package — имя пакета-хранилища (по умолчанию ratatoskr).
Package string
// Token — токен доступа к приватным пакетам (может быть пустым).
Token string
// CurrentVersion — вшитая в бинарь версия (ldflag -X).
CurrentVersion string
// Dir — каталог рядом с бинарём (для .new/.old и companion-метаданных).
// Ставится app из os.Executable(); если пуст — используется каталог Workdir.
Dir string
}
// Result — результат Check.
type Result struct {
// UpdateAvailable — true, если новейшая версия отличается от вшитой.
UpdateAvailable bool
// Version — идентификатор новейшей применимой версии (commit-<sha7>).
Version string
// Err — ошибка проверки (U1), нефатальная. Если != nil, остальные поля —
// нулевые, и качать нельзя.
Err error
}
// ErrCode — классифицирует ошибки update (U1..U6).
type ErrCode string
const (
U1 ErrCode = "U1 ErrCheckFailed" // сеть/401 → молча пропустить
U2 ErrCode = "U2 ErrUnsupportedOS" // нет бинаря для платформы
U3 ErrCode = "U3 ErrDownloadFailed" // HTTP не 200 / обрыв
U4 ErrCode = "U4 ErrChecksumMismatch" // sha256 не сошёлся → НЕ заменять
U5 ErrCode = "U5 ErrSwapFailed" // не смог перезаписать занятый файл
U6 ErrCode = "U6 ErrRestartFailed" // новый процесс не поднялся
)
type Error struct {
Code ErrCode
Op string
Err error
}
func (e *Error) Error() string {
if e.Err == nil {
return string(e.Code) + ": " + e.Op
}
return string(e.Code) + ": " + e.Op + ": " + e.Err.Error()
}
func (e *Error) Unwrap() error { return e.Err }
// isErr — фабрика ошибок классификатора.
func ue(code ErrCode, op string, err error) *Error { return &Error{Code: code, Op: op, Err: err} }
// Filename — имя бинаря целевой платформы (совпадает с матрицей CI).
func Filename(goos, goarch string) string {
exe := ""
if goos == "windows" {
exe = ".exe"
}
return fmt.Sprintf("ratatoskr-%s-%s%s", goos, goarch, exe)
}
// PlatformFilename — имя бинаря для текущей платформы.
func PlatformFilename() string { return Filename(runtime.GOOS, runtime.GOARCH) }
// fileURL собирает URL файла в хранилище конкретной версии (НЕ latest).
func (u *Updater) fileURL(version, name string) string {
base := strings.TrimRight(u.BaseURL, "/")
return base + "/api/packages/" + url.PathEscape(u.Owner) + "/generic/" +
url.PathEscape(u.Package) + "/" + url.PathEscape(version) + "/" + url.PathEscape(name)
}
// versionsURL — URL листинга версий пакета.
func (u *Updater) versionsURL() string {
base := strings.TrimRight(u.BaseURL, "/")
return base + "/api/v1/packages/" + url.PathEscape(u.Owner) + "/generic/" + url.PathEscape(u.Package)
}
// httpGet скачивает файл по URL бэкенда. При Token непустом — Basic/токен-заголовок.
func (u *Updater) httpGet(url string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if u.Token != "" {
req.Header.Set("Authorization", "token "+u.Token)
}
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, ue(U3, "GET "+url, fmt.Errorf("HTTP %d", resp.StatusCode))
}
return io.ReadAll(resp.Body)
}
// Check определяет, есть ли более свежая версия в Gitea Packages.
// Не скачивает бинарь. Ошибка (U1) возвращается в Result.Err — вызывающий
// решает, логировать и пропустить.
func (u *Updater) Check(ctx context.Context) Result {
if err := ctx.Err(); err != nil {
return Result{Err: ue(U1, "check", err)}
}
ver, err := u.ResolveLatest(ctx)
if err != nil {
return Result{Err: ue(U1, "check", err)}
}
if ver == "" {
return Result{Err: ue(U1, "check", errors.New("no applicable commit-* version found"))}
}
avail := u.CurrentVersion != "" && ver != u.CurrentVersion
return Result{UpdateAvailable: avail, Version: ver}
}
// pkgVersion — одна запись из сплющенного листинга версий Gitea.
// GET /api/v1/packages/{owner}/generic/{package} возвращает массив таких:
// каждая запись (id, version, html_url) — отдельная версия пакета.
type pkgVersion struct {
ID int64 `json:"id"`
Ver string `json:"version"`
}
// ResolveLatest определяет идентификатор новейшей применимой версии пакета.
// Бинарь/метаданные читаем из КОНКРЕТНОЙ версии, а не из pseudo-`latest`,
// чтобы companion-файлы и бинарь всегда брались из одного снимка.
func (u *Updater) ResolveLatest(ctx context.Context) (string, error) {
b, err := u.httpGet(u.versionsURL())
if err != nil {
return "", err
}
var vers []pkgVersion
if err := json.Unmarshal(b, &vers); err != nil {
return "", ue(U1, "list "+u.versionsURL(), err)
}
want := PlatformFilename()
// выбираем самую свежую версию (макс. id) класса commit-*, в которой есть бинарь.
best := ""
var bestID int64
for _, v := range vers {
if !strings.HasPrefix(v.Ver, "commit-") {
continue
}
// версия применима, только если в ней опубликован бинарь нашей платформы
if _, err := u.httpGet(u.fileURL(v.Ver, want)); err != nil {
continue
}
if v.ID > bestID {
bestID = v.ID
best = v.Ver
}
}
return best, nil
}
// Download скачивает бинарь конкретной версии во временный файл и возвращает
// путь к нему. Файл: <Dir>/.ratatoskr.<ver>.new.
func (u *Updater) Download(ctx context.Context, version string) (string, error) {
b, err := u.httpGet(u.fileURL(version, PlatformFilename()))
if err != nil {
return "", err // уже U3
}
dir := u.binDir()
if dir == "" {
return "", ue(U3, "download", errors.New("binDir пуст"))
}
tmp := filepath.Join(dir, ".ratatoskr."+sanitizeVer(version)+".new")
if err := os.WriteFile(tmp, b, 0o755); err != nil {
return "", ue(U3, "download write "+tmp, err)
}
return tmp, nil
}
// versionSum256 читает companion-файл контрольной суммы конкретной версии.
func (u *Updater) versionSum256(ctx context.Context, version string) (string, error) {
b, err := u.httpGet(u.fileURL(version, PlatformFilename()+".sha256"))
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}
// sha256hex — hex-сумма байтов.
func sha256hex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
// Verify проверяет контрольную сумму скачанного файла против .sha256 ТОЙ ЖЕ версии.
func (u *Updater) Verify(ctx context.Context, version, file string) error {
b, err := os.ReadFile(file)
if err != nil {
return ue(U4, "verify read", err)
}
sum, err := u.versionSum256(ctx, version)
if err != nil {
return err
}
if sum != "" && sha256hex(b) != sum {
return ue(U4, "verify", fmt.Errorf("got %s want %s", sha256hex(b), sum))
}
return nil
}
// sanitizeVer очищает версию до безопасного имени файла.
func sanitizeVer(v string) string {
v = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '.':
return r
}
return '_'
}, v)
if v == "" {
return "unknown"
}
return v
}
// CrashSafeTag — имя резервного файла старого бинаря (для отката U6).
const CrashSafeTag = ".ratatoskr.old"
// binDir возвращает каталог для временных файлов (.new/.old) и метаданных.
func (u *Updater) binDir() string {
if u.Dir != "" {
return u.Dir
}
exe, err := os.Executable()
if err != nil {
return "."
}
return filepath.Dir(exe)
}
// ApplyPendingSwap применяет скачанный .new файл при следующем старте
// процесса (до старта подсистем). Кроссплатформенно: rename работающего exe
// разрешён и на Windows, и на Linux. Если .new нет — делает ничего (возвращает
// nil). Текущий бинарь резервируется в .old для возможного отката.
// Тестам можно передать exePath через прямой вызов applySwap(dir, exePath).
func (u *Updater) ApplyPendingSwap() error {
exe, err := os.Executable()
if err != nil {
return ue(U5, "apply-pending executable", err)
}
return u.applySwap(filepath.Dir(exe), exe)
}
// applySwap — ядро свапа: ищет .new в dir, резервирует old, ставит новый exe.
// Разделено для тестируемости. Возвращает nil, если .new нет.
func (u *Updater) applySwap(dir, exe string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return nil // каталог недоступен — не критично
}
newFile := ""
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".new") && strings.HasPrefix(e.Name(), ".ratatoskr.") {
newFile = filepath.Join(dir, e.Name())
break
}
}
if newFile == "" {
return nil
}
old := filepath.Join(dir, CrashSafeTag)
if err := os.Rename(exe, old); err != nil {
return ue(U5, "rename current->.old", err)
}
if err := os.Rename(newFile, exe); err != nil {
_ = os.Rename(old, exe)
return ue(U5, "rename .new->exe", err)
}
_ = os.Chmod(exe, 0o755)
return nil
}
// SwapAndRestart применяет .new сразу и перезапускается (POSIX: rename поверх +
// exec себя). Возвращает U5 при неудачном swap; при успешном swap вызывает
// exec нового процесса и os.Exit(0). На Windows мгновенный рестарт невозможен:
// .new переносится так, чтобы свап случился при следующем старте через
// ApplyPendingSwap. Резерв .old позволяет откатиться при неудачном старте.
func (u *Updater) SwapAndRestart(file string) error {
if runtime.GOOS == "windows" {
return ue(U2, "windows instant restart не поддерживается; применится при рестарте", nil)
}
exe, err := os.Executable()
if err != nil {
return ue(U5, "swap executable", err)
}
old := filepath.Join(filepath.Dir(exe), CrashSafeTag)
if err := os.Rename(exe, old); err != nil {
return ue(U5, "rename current->.old", err)
}
if err := os.Rename(file, exe); err != nil {
_ = os.Rename(old, exe)
return ue(U5, "rename .new->exe", err)
}
_ = os.Chmod(exe, 0o755)
// exec себя: старт нового процесса с теми же аргументами; текущий exit(0).
cmd := exec.Command(exe, os.Args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
// откат старого бинаря
_ = os.Rename(exe, file) // вернём скачанный файл на .new
_ = os.Rename(old, exe)
return ue(U6, "restart", err)
}
// успешный старт нового процесса — текущий завершаем
os.Exit(0)
return nil
}