Files
ratatoskr-go/internal/update/update.go
Hermes 44ac7d73bb
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
feat: автообновление бинаря из Gitea Packages (/update, /status, авто-уведомление)
2026-08-16 11:50:23 +05:00

309 lines
12 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`,
// с тремя 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
}