// Package update — система автообновления бинаря ratatoskr. // // Источник: Gitea Packages (generic), хранилище `ratatoskr`, // с тремя companion-файлами актуальной (latest) версии: // - сам бинарь // - .version текстовый идентификатор версии (commit-) // - .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-файл .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. Файл: /.ratatoskr..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 }