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