Files
ratatoskr-go/internal/update/update.go
ki.sagidullin cd0619926e
Some checks failed
CI / test (push) Failing after 1m15s
CI / build-and-package (amd64, linux) (push) Failing after 58s
CI / build-and-package (amd64, windows) (push) Successful in 30s
perf(chat,update): пул воркеров per-user вместо сериальной очереди + HEAD-проба обновлений
- chat.Router: ограниченный пул chatWorkers=4 воркеров + FIFO-очереди
  per-user (userState/workerLoop/runUser). Порядок сообщений одного UserID
  сохраняется; разные пользователи обрабатываются параллельно (до 4
  одновременных LLM-вызовов), long-poll Telegram не блокируется чужим
  аналитиком. Backpressure по jobs — только на перегруженного пользователя.
- app.FreeChat: sessions под sync.Mutex (защита от data race при параллельных
  воркерах роутера).
- update: ResolveLatest проверяет наличие бинаря HEAD-пробой без скачивания
  тела (fallback GET Range 0-0 при 405/501), сортировка версий по id убыв.;
  один общий http.Client (keep-alive) вместо нового на каждый запрос.
- тесты: порядок/параллелизм per-user в router, HEAD-без-тела и фоллбэк на
  версию без бинаря в update.
- память Serena: инварианты Router/update, примечания по форматированию на Windows.
2026-08-22 11:44:34 +05:00

430 lines
17 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"
"sort"
"strings"
"sync"
"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
// client — общий HTTP-клиент (keep-alive), чтобы проверки/скачивания
// переиспользовали соединения, а не создавали новое на каждый запрос.
client *http.Client
clientMu sync.Mutex
}
// 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)
}
// httpClient возвращает общий клиент (keep-alive), инициализируя лениво.
func (u *Updater) httpClient() *http.Client {
u.clientMu.Lock()
defer u.clientMu.Unlock()
if u.client == nil {
u.client = &http.Client{Timeout: 30 * time.Second}
}
return u.client
}
// httpGet скачивает файл по URL бэкенда. При Token непустом — Basic/токен-заголовок.
func (u *Updater) httpGet(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if u.Token != "" {
req.Header.Set("Authorization", "token "+u.Token)
}
resp, err := u.httpClient().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)
}
// fileExists проверяет наличие файла по URL без скачивания тела: HEAD,
// а при 405/501 (сервер не поддерживает HEAD) — fallback на GET с Range байт 0-0.
// Возвращает (false, nil) при 404/410 — файла нет.
func (u *Updater) fileExists(ctx context.Context, url string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return false, err
}
if u.Token != "" {
req.Header.Set("Authorization", "token "+u.Token)
}
resp, err := u.httpClient().Do(req)
if err != nil {
return false, err
}
resp.Body.Close()
switch {
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
return false, nil
case resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent:
return true, nil
case resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented:
// Gitea может не отвечать на HEAD — проверяем GET с Range 0-0 без чтения тела.
return u.fileExistsByRange(ctx, url)
default:
return false, nil
}
}
// fileExistsByRange проверяет наличие файла GET'ом с Range: bytes=0-0.
// Тело не читается: достаточно лишь первых байт заголовков ответа.
func (u *Updater) fileExistsByRange(ctx context.Context, url string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return false, err
}
if u.Token != "" {
req.Header.Set("Authorization", "token "+u.Token)
}
req.Header.Set("Range", "bytes=0-0")
resp, err := u.httpClient().Do(req)
if err != nil {
return false, err
}
resp.Body.Close()
switch {
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
return false, nil
case resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent:
return true, nil
default:
return false, nil
}
}
// 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-файлы и бинарь всегда брались из одного снимка.
//
// Применимость версии проверяем НАЛИЧИЕМ бинаря платформы (HEAD без тела),
// а не скачиванием полного файла: при N версиях это N запросов заголовков
// вместо N×(размер бинаря) байт.
func (u *Updater) ResolveLatest(ctx context.Context) (string, error) {
b, err := u.httpGet(ctx, 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)
}
// новые версии — с большим ID; идём с новейшей и берём первую с бинарём.
sort.SliceStable(vers, func(i, j int) bool { return vers[i].ID > vers[j].ID })
want := PlatformFilename()
for _, v := range vers {
if !strings.HasPrefix(v.Ver, "commit-") {
continue
}
exists, ferr := u.fileExists(ctx, u.fileURL(v.Ver, want))
if ferr != nil {
continue // сетевые ошибки пробы не роняют проверку
}
if exists {
return v.Ver, nil
}
}
return "", nil
}
// Download скачивает бинарь конкретной версии во временный файл и возвращает
// путь к нему. Файл: <Dir>/.ratatoskr.<ver>.new.
func (u *Updater) Download(ctx context.Context, version string) (string, error) {
b, err := u.httpGet(ctx, 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(ctx, 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
}