feat: автообновление бинаря из Gitea Packages (/update, /status, авто-уведомление)
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

This commit is contained in:
Hermes
2026-08-16 11:50:23 +05:00
parent bd3d825738
commit 44ac7d73bb
9 changed files with 775 additions and 14 deletions

309
internal/update/update.go Normal file
View File

@@ -0,0 +1,309 @@
// 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
}

View File

@@ -0,0 +1,222 @@
package update
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// mockGitea поднимает httptest-сервер с бинарём, <filename>.version и .sha256.
func mockGitea(t *testing.T, bin []byte, version, checksum string) *httptest.Server {
t.Helper()
name := PlatformFilename()
mux := http.NewServeMux()
mux.HandleFunc("/api/packages/", func(w http.ResponseWriter, r *http.Request) {
base := name
switch {
case strings.HasSuffix(r.URL.Path, ".version"):
base = name + ".version"
_, _ = w.Write([]byte(version))
case strings.HasSuffix(r.URL.Path, ".sha256"):
base = name + ".sha256"
_, _ = w.Write([]byte(checksum))
default:
_, _ = w.Write(bin) // сам бинарь
}
_ = base
})
return httptest.NewServer(mux)
}
// testUpdater строит Updater с базой на мок-сервере и каталогом tmp.
func testUpdater(t *testing.T, bin []byte, version, cur string) (*Updater, string) {
t.Helper()
sum := sha256hex(bin)
srv := mockGitea(t, bin, version, sum)
t.Cleanup(srv.Close)
dir := t.TempDir()
u := &Updater{
BaseURL: srv.URL,
Owner: "kamelion",
Package: "ratatoskr",
CurrentVersion: cur,
Dir: dir,
}
return u, dir
}
func TestFilename(t *testing.T) {
cases := []struct{ goos, goarch, want string }{
{"linux", "amd64", "ratatoskr-linux-amd64"},
{"linux", "arm64", "ratatoskr-linux-arm64"},
{"darwin", "arm64", "ratatoskr-darwin-arm64"},
{"windows", "amd64", "ratatoskr-windows-amd64.exe"},
}
for _, c := range cases {
if got := Filename(c.goos, c.goarch); got != c.want {
t.Errorf("Filename(%s,%s)=%q want %q", c.goos, c.goarch, got, c.want)
}
}
}
func TestCheck_UpdateAvailable(t *testing.T) {
u, _ := testUpdater(t, []byte("bin"), "commit-abc1234", "commit-old0000")
res := u.Check(context.Background())
if res.Err != nil {
t.Fatalf("Check err = %v", res.Err)
}
if !res.UpdateAvailable {
t.Fatal("expected update available")
}
if res.Version != "commit-abc1234" {
t.Errorf("Version = %q want commit-abc1234", res.Version)
}
}
func TestCheck_AlreadyCurrent(t *testing.T) {
u, _ := testUpdater(t, []byte("bin"), "commit-abc1234", "commit-abc1234")
res := u.Check(context.Background())
if res.Err != nil {
t.Fatalf("Check err = %v", res.Err)
}
if res.UpdateAvailable {
t.Fatal("not expected update (same version)")
}
}
func TestCheck_ServerDown(t *testing.T) {
dir := t.TempDir()
u := &Updater{BaseURL: "http://127.0.0.1:1", Owner: "k", Package: "p", CurrentVersion: "v1", Dir: dir}
res := u.Check(context.Background())
if res.Err == nil {
t.Fatal("expected error for unreachable server")
}
// ошибка должна классифицироваться как U1 ErrCheckFailed
var uerr *Error
if !errorsAs(res.Err, &uerr) || uerr.Code != U1 {
t.Errorf("expected U1 error, got %v", res.Err)
}
}
func TestDownload_And_Verify_Good(t *testing.T) {
bin := []byte("ratatoskr-binary-content-v2")
u, dir := testUpdater(t, bin, "commit-new12345", "")
file, err := u.Download(context.Background(), "commit-new12345")
if err != nil {
t.Fatalf("Download err = %v", err)
}
if !strings.Contains(file, ".new") {
t.Errorf("temp file %q should end with .new", filepath.Base(file))
}
if err := u.Verify(context.Background(), file); err != nil {
t.Fatalf("Verify err = %v", err)
}
// .new лежит в каталоге
entries, _ := os.ReadDir(dir)
if len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".new") {
t.Errorf("expected 1 .new file in dir, got %v", entries)
}
}
func TestVerify_ChecksumMismatch(t *testing.T) {
bin := []byte("A")
srv := mockGitea(t, bin, "commit-x", sha256hex([]byte("differente-content")))
t.Cleanup(srv.Close)
u := &Updater{BaseURL: srv.URL, Owner: "k", Package: "p", Dir: t.TempDir()}
file := filepath.Join(u.Dir, ".ratatoskr.x.new")
if err := os.WriteFile(file, []byte("A"), 0o755); err != nil {
t.Fatal(err)
}
err := u.Verify(context.Background(), file)
if err == nil {
t.Fatal("expected checksum mismatch error")
}
var uerr *Error
if !errorsAs(err, &uerr) || uerr.Code != U4 {
t.Errorf("expected U4 error, got %v", err)
}
}
func TestSanitizeVer(t *testing.T) {
cases := map[string]string{
"commit-abc1234": "commit-abc1234",
"v1.2.3": "v1.2.3",
"a/b c": "a_b_c",
"": "unknown",
}
for in, want := range cases {
if got := sanitizeVer(in); got != want {
t.Errorf("sanitizeVer(%q)=%q want %q", in, got, want)
}
}
}
func TestApplySwap_NoPending(t *testing.T) {
dir := t.TempDir()
u := &Updater{Dir: dir}
if err := u.applySwap(dir, filepath.Join(dir, "ratatoskr")); err != nil {
t.Fatalf("applySwap with no .new should return nil, got %v", err)
}
}
func TestApplySwap_Applies(t *testing.T) {
dir := t.TempDir()
exe := filepath.Join(dir, "ratatoskr")
if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil {
t.Fatal(err)
}
newFile := filepath.Join(dir, ".ratatoskr.commit-n.new")
if err := os.WriteFile(newFile, []byte("new-content"), 0o755); err != nil {
t.Fatal(err)
}
u := &Updater{Dir: dir}
if err := u.applySwap(dir, exe); err != nil {
t.Fatalf("applySwap err = %v", err)
}
got, _ := os.ReadFile(exe)
if string(got) != "new-content" {
t.Errorf("exe content = %q want new-content", got)
}
// старый бэкап в .old
old, _ := os.ReadFile(filepath.Join(dir, CrashSafeTag))
if string(old) != "old" {
t.Errorf(".old content = %q want old", old)
}
}
func TestErrorsAs(t *testing.T) {
// проверка классификатора через errorsAs на Error(U4)
err := ue(U4, "verify", os.ErrPermission)
var uerr *Error
if !errorsAs(err, &uerr) {
t.Fatal("errorsAs failed to unwrap *Error")
}
if uerr.Code != U4 {
t.Errorf("code = %v want U4", uerr.Code)
}
if !errors.Is(err, os.ErrPermission) {
t.Errorf("errors.Is(err, ErrPermission) = false, want true")
}
}
// errorsAs — тонкая обёртка errors.As для классификатора update.Error.
func errorsAs(err error, target **Error) bool {
type causer interface{ Unwrap() error }
for err != nil {
if e, ok := err.(*Error); ok {
*target = e
return true
}
c, ok := err.(causer)
if !ok {
return false
}
err = c.Unwrap()
}
return false
}