All checks were successful
CI / test (push) Successful in 47s
CI / build-and-package (amd64, darwin) (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 40s
CI / build-and-package (amd64, windows) (push) Successful in 39s
CI / build-and-package (arm64, darwin) (push) Successful in 40s
CI / build-and-package (arm64, linux) (push) Successful in 40s
309 lines
12 KiB
Go
309 lines
12 KiB
Go
// Package update — система автообновления бинаря ratatoskr.
|
||
//
|
||
// Источник: Gitea Packages (generic), хранилище `ratatoskr`,
|
||
// с тремя companion-файлами актуальной (latest) версии:
|
||
// - <filename> сам бинарь
|
||
// - <filename>.version текстовый идентификатор версии (commit-<sha7>)
|
||
// - <filename>.sha256 hex-контрольная сумма бинаря
|
||
//
|
||
// Модель обновления: «только по команде /update». Авто-режим (по расписанию
|
||
// и при старте) делает ТОЛЬКО Check и уведомляет владельца в чат — ничего не
|
||
// качает и не трогает работающий процесс.
|
||
package update
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"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, если latest отличается от вшитой версии.
|
||
UpdateAvailable bool
|
||
// Version — идентификатор свежей (latest) версии.
|
||
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 companion-файла (версии/суммы) в хранилище latest.
|
||
func (u *Updater) fileURL(name string) string {
|
||
base := strings.TrimRight(u.BaseURL, "/")
|
||
return base + "/api/packages/" + u.Owner + "/generic/" + u.Package + "/latest/" + name
|
||
}
|
||
|
||
// 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 определяет, есть ли более свежая версия в хранилище latest.
|
||
// Не скачивает бинарь. Ошибка (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.latestVersion(ctx)
|
||
if err != nil {
|
||
return Result{Err: ue(U1, "check", err)}
|
||
}
|
||
if ver == "" {
|
||
return Result{Err: ue(U1, "check", errors.New("latest version file empty"))}
|
||
}
|
||
avail := u.CurrentVersion != "" && ver != u.CurrentVersion
|
||
return Result{UpdateAvailable: avail, Version: ver}
|
||
}
|
||
|
||
// latestVersion читает companion-файл <filename>.version хранилища latest.
|
||
func (u *Updater) latestVersion(ctx context.Context) (string, error) {
|
||
b, err := u.httpGet(u.fileURL(PlatformFilename() + ".version"))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return strings.TrimSpace(string(b)), nil
|
||
}
|
||
|
||
// Download скачивает бинарь latest во временный файл и возвращает путь к нему.
|
||
// Поток проверки: метаданные уже получены Check. Файл: <Dir>/.ratatoskr.<ver>.new.
|
||
func (u *Updater) Download(ctx context.Context, version string) (string, error) {
|
||
b, err := u.httpGet(u.fileURL(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
|
||
}
|
||
|
||
// latestSum256 читает companion-файл контрольной суммы latest.
|
||
func (u *Updater) latestSum256(ctx context.Context) (string, error) {
|
||
b, err := u.httpGet(u.fileURL(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 проверяет контрольную сумму скачанного файла против latest-метаданных.
|
||
func (u *Updater) Verify(ctx context.Context, file string) error {
|
||
b, err := os.ReadFile(file)
|
||
if err != nil {
|
||
return ue(U4, "verify read", err)
|
||
}
|
||
sum, err := u.latestSum256(ctx)
|
||
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
|
||
} |