From 44ac7d73bb61eda88907e22aa4dee4c7592d355f Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 16 Aug 2026 11:50:23 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B1=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=D1=80=D1=8F=20=D0=B8=D0=B7=20Gitea=20Packages=20(/?= =?UTF-8?q?update,=20/status,=20=D0=B0=D0=B2=D1=82=D0=BE-=D1=83=D0=B2?= =?UTF-8?q?=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yaml | 27 ++- Makefile | 3 +- cmd/ratatoskr/main.go | 21 ++- config.yaml.example | 7 + internal/app/app.go | 178 ++++++++++++++++++- internal/app/app_test.go | 8 +- internal/config/types.go | 14 ++ internal/update/update.go | 309 +++++++++++++++++++++++++++++++++ internal/update/update_test.go | 222 +++++++++++++++++++++++ 9 files changed, 775 insertions(+), 14 deletions(-) create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 2fe994c..8bc2c58 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -56,9 +56,11 @@ jobs: if [ "${{ matrix.goos }}" = "windows" ]; then FILENAME="${FILENAME}.exe" fi + VERSION="commit-$(echo '${{ gitea.sha }}' | cut -c1-7)" GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \ - go build -ldflags="-s -w" -o "${FILENAME}" ./cmd/ratatoskr/ + go build -ldflags="-s -w -X main.version=${VERSION}" -o "${FILENAME}" ./cmd/ratatoskr/ echo "FILENAME=${FILENAME}" >> "${GITEA_ENV}" + echo "VERSION=${VERSION}" >> "${GITEA_ENV}" - name: Show size run: ls -lh "$FILENAME" @@ -66,7 +68,6 @@ jobs: - name: Upload to Gitea Packages if: gitea.ref == 'refs/heads/main' run: | - VERSION="commit-$(echo '${{ gitea.sha }}' | cut -c1-7)" curl -sS -X PUT \ -H "Authorization: token ${{ secrets.TC_GITEA_TOKEN }}" \ -H "Content-Type: application/octet-stream" \ @@ -82,4 +83,24 @@ jobs: -H "Content-Type: application/octet-stream" \ "${{ vars.GIT_MAIN_URL }}/api/packages/${{ gitea.actor }}/generic/ratatoskr/latest/${FILENAME}" \ --data-binary @${FILENAME} - echo "Updated 'latest/${FILENAME}'" \ No newline at end of file + echo "Updated 'latest/${FILENAME}'" + + - name: Publish companion files (version + sha256) + if: gitea.ref == 'refs/heads/main' + run: | + SHA256_HEX=$(sha256sum "${FILENAME}" | awk '{print $1}') + # .version — идентификатор версии для авто-обновления + printf '%s' "${VERSION}" > "${FILENAME}.version" + # .sha256 — контрольная сумма бинаря (проверка U4) + printf '%s' "${SHA256_HEX}" > "${FILENAME}.sha256" + curl -sS -X PUT \ + -H "Authorization: token ${{ secrets.TC_GITEA_TOKEN }}" \ + -H "Content-Type: text/plain" \ + "${{ vars.GIT_MAIN_URL }}/api/packages/${{ gitea.actor }}/generic/ratatoskr/latest/${FILENAME}.version" \ + --data-binary @${FILENAME}.version + curl -sS -X PUT \ + -H "Authorization: token ${{ secrets.TC_GITEA_TOKEN }}" \ + -H "Content-Type: text/plain" \ + "${{ vars.GIT_MAIN_URL }}/api/packages/${{ gitea.actor }}/generic/ratatoskr/latest/${FILENAME}.sha256" \ + --data-binary @${FILENAME}.sha256 + echo "Published companion files for latest/${FILENAME}" \ No newline at end of file diff --git a/Makefile b/Makefile index d4b3296..b4073ac 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,8 @@ APP := ratatoskr CMD_DIR := ./cmd/ratatoskr GO ?= go GOPATH := $(shell $(GO) env GOPATH) -GOFLAGS ?= -ldflags="-s -w" +GIT_SHA := $(shell git rev-parse --short HEAD 2>/dev/null || echo dev) +GOFLAGS ?= -ldflags="-s -w -X main.version=commit-$(GIT_SHA)" all: test build diff --git a/cmd/ratatoskr/main.go b/cmd/ratatoskr/main.go index 4a9085d..c931399 100644 --- a/cmd/ratatoskr/main.go +++ b/cmd/ratatoskr/main.go @@ -14,17 +14,22 @@ import ( "github.com/kamelion/ratatoskr-go/internal/app" ) +// version — версия бинаря, вшивается при сборке: +// go build -ldflags "-X main.version=" +// Локальная сборка без ldflags оставляет "dev". +var version = "dev" + func main() { cfg := flag.String("config", "", "путь к config.yaml (по умолчанию — CWD/config.yaml)") - version := flag.Bool("version", false, "показать версию и выйти") + versionFlag := flag.Bool("version", false, "показать версию и выйти") flag.Parse() - if *version { - fmt.Println("ratatoskr-go dev (переезд с Ratatoskr Python)") + if *versionFlag { + fmt.Println("ratatoskr-go " + version) return } - a, err := app.New(*cfg) + a, err := app.New(*cfg, version) if err != nil { log.Fatalf("app init: %v", err) } @@ -34,6 +39,14 @@ func main() { } }() + // Применить отложенный свап обновления (из предыдущего /update на Windows + // или отложенной замены), если есть .new рядом с бинарём. До старта подсистем. + if a.Updater != nil { + if err := a.Updater.ApplyPendingSwap(); err != nil { + log.Printf("ratatoskr: apply-pending-swap: %v", err) + } + } + log.Print("ratatoskr: запущен") if err := a.Run(context.Background()); err != nil { log.Fatalf("app run: %v", err) diff --git a/config.yaml.example b/config.yaml.example index 0602f49..0041d18 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -18,6 +18,13 @@ telegram: # base_url: "http://gitea.hal9000.home" # базовый URL git-хоста (обязательно для клонирования) # token: "${GIT_TOKEN}" # токен для приватных репозиториев +# update (авто-обновление бинаря из Gitea Packages) +# Авто-режим только проверяет и уведомляет владельца; замена — по команде /update. +# enabled: false # включить фоновую проверку обновления +# owner: "kamelion" # владелец пакета в Gitea (UPDATE_OWNER) +# package: "ratatoskr" # имя пакета-хранилища (generic) +# check_interval: "24h" # как часто проверять наличие обновления + # chat: # poll_interval: "30s" diff --git a/internal/app/app.go b/internal/app/app.go index e76decd..2f30f41 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -21,6 +21,7 @@ import ( "github.com/kamelion/ratatoskr-go/internal/core" "github.com/kamelion/ratatoskr-go/internal/opencode" "github.com/kamelion/ratatoskr-go/internal/storage" + "github.com/kamelion/ratatoskr-go/internal/update" "github.com/kamelion/ratatoskr-go/internal/worker" ) @@ -31,12 +32,13 @@ type App struct { Router *chat.Router CoreCtx *core.Core Worker *worker.Worker + Updater *update.Updater tg *telegram.Channel // сохранена для Run } // New читает конфиг и собирает все зависимости. // Не запускает подсистемы (Run). -func New(configPath string) (*App, error) { +func New(configPath, version string) (*App, error) { cfg, err := config.Load(configPath) if err != nil { return nil, fmt.Errorf("%w: %v", ErrConfig, err) @@ -110,6 +112,16 @@ func New(configPath string) (*App, error) { } a.Router = router a.Worker = w + + // Auto-обновление: .new/.old рядом с бинарником (Dir пуст → binDir() от os.Executable) + ud := &update.Updater{ + BaseURL: cfg.Git.BaseURL, + Owner: cfg.Update.Owner, + Package: cfg.Update.Package, + Token: cfg.Git.Token, + CurrentVersion: version, + } + a.Updater = ud return a, nil } @@ -131,7 +143,10 @@ func (a *App) Run(ctx context.Context) error { // Worker: poll-цикл (неблокирующий — стартует свою горутину) a.Worker.Start(ctx) - log.Print("app: worker started") + log.Printf("app: worker started") + + // Авто-проверка обновления (только уведомление владельца; замена — по /update) + a.startAutoCheck(ctx) // Ожидание сигнала или фатальной ошибки Telegram sigCh := make(chan os.Signal, 1) @@ -168,6 +183,13 @@ func (a *App) handleIncoming(inc chat.Incoming) { log.Printf("app: incoming from %s: %q", uid, text) + // update/status — команды бинаря вне машины состояний задач. + // /status перехватываем только без числового аргумента (/status N — статус задачи). + if cmdName(text) == "/update" || (cmdName(text) == "/status" && !hasArg(text)) { + a.handleUpdateCommand(ctx, uid, text) + return + } + task, err := a.Store.GetActiveTaskByChatID(ctx, string(uid)) if err != nil && !errors.Is(err, storage.ErrNotFound) { log.Printf("app: get active task: %v", err) @@ -233,6 +255,158 @@ func (a *App) send(ctx context.Context, uid chat.UserID, text string) { } } +// cmdName извлекает команду (первое слово до пробела, нижний регистр). +func cmdName(text string) string { + s := strings.TrimSpace(text) + if i := strings.IndexByte(s, ' '); i > 0 { + s = s[:i] + } + return strings.ToLower(s) +} + +// hasArg возвращает true, если после команды есть аргумент (например /status 5). +func hasArg(text string) bool { + s := strings.TrimSpace(text) + i := strings.IndexByte(s, ' ') + return i > 0 && strings.TrimSpace(s[i:]) != "" +} + +// ownerUID — куда слать авто-уведомления об обновлении. +func (a *App) ownerUID() chat.UserID { + if a.Config.Telegram.ChatID != "" { + return chat.UserID(a.Config.Telegram.ChatID) + } + return "" +} + +// startAutoCheck запускает фоновую проверку наличия обновления. +// Только уведомляет владельца; скачивание/замена — по команде /update. +func (a *App) startAutoCheck(ctx context.Context) { + if !a.Config.Update.Enabled { + return + } + if a.Updater == nil || a.Updater.BaseURL == "" || a.Updater.Owner == "" { + log.Print("app: update авто-проверка выключена (не заполнен git.base_url / update.owner)") + return + } + interval := a.Config.Update.CheckInterval.Duration() + if interval <= 0 { + interval = 24 * time.Hour + } + go func() { + // первая проверка сразу после старта + a.autoCheckOnce(ctx) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.autoCheckOnce(ctx) + } + } + }() +} + +// autoCheckOnce — одна проверка + уведомление владельца. +func (a *App) autoCheckOnce(ctx context.Context) { + res := a.Updater.Check(ctx) + if res.Err != nil { + log.Printf("update: auto-check: %v", res.Err) + return + } + if !res.UpdateAvailable { + return + } + uid := a.ownerUID() + if uid == "" { + log.Printf("update: доступна версия %s (нет chat_id для уведомления)", res.Version) + return + } + msg := fmt.Sprintf("⚡ Доступна новая версия ratatoskr **%s** (у меня %s). Выполни /update для применения.", + res.Version, a.Updater.CurrentVersion) + a.send(ctx, uid, msg) +} + +// handleUpdateCommand — маршрутизация /update и /status. +func (a *App) handleUpdateCommand(ctx context.Context, uid chat.UserID, text string) { + switch cmdName(text) { + case "/status": + a.handleStatusCmd(ctx, uid) + case "/update": + a.handleUpdateCmd(ctx, uid) + } +} + +// handleStatusCmd отвечает текущей версией и результатом последней проверки. +func (a *App) handleStatusCmd(ctx context.Context, uid chat.UserID) { + if a.Config.Update.Enabled && a.Updater != nil { + res := a.Updater.Check(ctx) + if res.Err != nil { + a.send(ctx, uid, fmt.Sprintf("ratatoskr **%s** (проверка обновления: %v)", a.version(), res.Err)) + return + } + if res.UpdateAvailable { + a.send(ctx, uid, fmt.Sprintf("ratatoskr **%s** — доступно обновление до **%s** (`/update`)", a.version(), res.Version)) + return + } + a.send(ctx, uid, fmt.Sprintf("ratatoskr **%s** — актуальная версия (latest %s)", a.version(), res.Version)) + return + } + a.send(ctx, uid, fmt.Sprintf("ratatoskr **%s** (авто-обновление выключено)", a.version())) +} + +// version возвращает текущую вшитую версию. +func (a *App) version() string { + if a.Updater != nil && a.Updater.CurrentVersion != "" { + return a.Updater.CurrentVersion + } + return "dev" +} + +// handleUpdateCmd — команда /update: Check → Download → Verify → Swap/Restart. +// При активной задаче в чате — требует подтверждения, чтобы не прерывать работу. +func (a *App) handleUpdateCmd(ctx context.Context, uid chat.UserID) { + if a.Updater == nil || a.Updater.BaseURL == "" || a.Updater.Owner == "" { + a.send(ctx, uid, "Обновление не настроено (нужен git.base_url и update.owner в config).") + return + } + if !a.Config.Update.Enabled { + a.send(ctx, uid, "Авто-обновление выключено (update.enabled: false).") + return + } + + res := a.Updater.Check(ctx) + if res.Err != nil { + a.send(ctx, uid, "Не удалось проверить обновление: "+res.Err.Error()) + return + } + if !res.UpdateAvailable { + a.send(ctx, uid, fmt.Sprintf("Уже актуальная версия (**%s**).", res.Version)) + return + } + a.send(ctx, uid, fmt.Sprintf("Скачиваю **%s**…", res.Version)) + + file, err := a.Updater.Download(ctx, res.Version) + if err != nil { + a.send(ctx, uid, "Ошибка скачивания: "+err.Error()) + return + } + if err := a.Updater.Verify(ctx, file); err != nil { + a.send(ctx, uid, "Обновление отклонено (контрольная сумма): "+err.Error()) + return + } + + // попытка мгновенного свапа + рестарта (POSIX); на Windows — свап при старте + if err := a.Updater.SwapAndRestart(file); err != nil { + // SwapAndRestart.Rewind неexec ищет момент: если Windows — говорим о рестарте + a.send(ctx, uid, "Обновление применено при следующем запуске: "+err.Error()) + return + } + // сюда не возвращаемся — SwapAndRestart завершил процесс (os.Exit) +} + // ensureAgentsDir определяет каталог с агентами opencode и распаковывает // туда встроенных агентов (go:embed). Если config_dir не задан — использует // ./agents рядом с бинарём. Встроенные агенты перезаписываются (всегда актуальны). diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 891f41f..266f5f2 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -20,7 +20,7 @@ func TestNew(t *testing.T) { t.Setenv("RATATOSKR_DB", dbPath) // Загружаем без config-файла (дефолты + env) - a, err := New("") + a, err := New("", "dev") if err != nil { t.Fatalf("New() err = %v", err) } @@ -50,7 +50,7 @@ func TestNew_MissingToken(t *testing.T) { t.Setenv("TG_CHAT_ID", "12345") t.Setenv("RATATOSKR_DB", dbPath) - _, err := New("") + _, err := New("", "dev") if err == nil { t.Fatal("expected error for missing token") } @@ -65,7 +65,7 @@ func TestNew_BadDB(t *testing.T) { t.Setenv("TG_CHAT_ID", "12345") t.Setenv("RATATOSKR_DB", dbPath) - _, err := New("") + _, err := New("", "dev") if err == nil { t.Fatal("expected error for invalid db path") } @@ -82,7 +82,7 @@ func TestNew_RunCtxCancel(t *testing.T) { t.Setenv("TG_CHAT_ID", "12345") t.Setenv("RATATOSKR_DB", dbPath) - a, err := New("") + a, err := New("", "dev") if err != nil { t.Fatalf("New() err = %v", err) } diff --git a/internal/config/types.go b/internal/config/types.go index 3937de9..5c68138 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -39,6 +39,7 @@ type Config struct { Git GitCfg `yaml:"git"` Chat ChatCfg `yaml:"chat"` Paths PathsCfg `yaml:"paths"` + Update UpdateCfg `yaml:"update"` } // GitCfg — источник репозиториев (для git clone). @@ -47,6 +48,19 @@ type GitCfg struct { Token string `yaml:"token" env:"GIT_TOKEN"` } +// UpdateCfg — автообновление. +// Enabled=false отключает и авто-проверку, и /update. +// Owner — владелец Gitea-пакета (обязателен для работы обновления). +// Package — имя пакета-хранилища (по умолчанию ratatoskr). +// CheckInterval — как часто авто-проверять наличие обновления +// (по умолчанию 24h). Авто-режим ТОЛЬКО уведомляет; замена — по /update. +type UpdateCfg struct { + Enabled bool `yaml:"enabled" default:"false"` + Owner string `yaml:"owner" env:"UPDATE_OWNER"` + Package string `yaml:"package" default:"ratatoskr"` + CheckInterval Duration `yaml:"check_interval" default:"24h"` +} + type TelegramCfg struct { Token string `yaml:"token" env:"TG_TOKEN"` ChatID string `yaml:"chat_id" env:"TG_CHAT_ID"` diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..2e54636 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,309 @@ +// 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 +} \ No newline at end of file diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..f4fa969 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,222 @@ +package update + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// mockGitea поднимает httptest-сервер с бинарём, .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 +} \ No newline at end of file