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

View File

@@ -56,9 +56,11 @@ jobs:
if [ "${{ matrix.goos }}" = "windows" ]; then if [ "${{ matrix.goos }}" = "windows" ]; then
FILENAME="${FILENAME}.exe" FILENAME="${FILENAME}.exe"
fi fi
VERSION="commit-$(echo '${{ gitea.sha }}' | cut -c1-7)"
GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \ 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 "FILENAME=${FILENAME}" >> "${GITEA_ENV}"
echo "VERSION=${VERSION}" >> "${GITEA_ENV}"
- name: Show size - name: Show size
run: ls -lh "$FILENAME" run: ls -lh "$FILENAME"
@@ -66,7 +68,6 @@ jobs:
- name: Upload to Gitea Packages - name: Upload to Gitea Packages
if: gitea.ref == 'refs/heads/main' if: gitea.ref == 'refs/heads/main'
run: | run: |
VERSION="commit-$(echo '${{ gitea.sha }}' | cut -c1-7)"
curl -sS -X PUT \ curl -sS -X PUT \
-H "Authorization: token ${{ secrets.TC_GITEA_TOKEN }}" \ -H "Authorization: token ${{ secrets.TC_GITEA_TOKEN }}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
@@ -82,4 +83,24 @@ jobs:
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
"${{ vars.GIT_MAIN_URL }}/api/packages/${{ gitea.actor }}/generic/ratatoskr/latest/${FILENAME}" \ "${{ vars.GIT_MAIN_URL }}/api/packages/${{ gitea.actor }}/generic/ratatoskr/latest/${FILENAME}" \
--data-binary @${FILENAME} --data-binary @${FILENAME}
echo "Updated 'latest/${FILENAME}'" echo "Updated 'latest/${FILENAME}'"
- name: Publish companion files (version + sha256)
if: gitea.ref == 'refs/heads/main'
run: |
SHA256_HEX=$(sha256sum "${FILENAME}" | awk '{print $1}')
# <filename>.version — идентификатор версии для авто-обновления
printf '%s' "${VERSION}" > "${FILENAME}.version"
# <filename>.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}"

View File

@@ -4,7 +4,8 @@ APP := ratatoskr
CMD_DIR := ./cmd/ratatoskr CMD_DIR := ./cmd/ratatoskr
GO ?= go GO ?= go
GOPATH := $(shell $(GO) env GOPATH) 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 all: test build

View File

@@ -14,17 +14,22 @@ import (
"github.com/kamelion/ratatoskr-go/internal/app" "github.com/kamelion/ratatoskr-go/internal/app"
) )
// version — версия бинаря, вшивается при сборке:
// go build -ldflags "-X main.version=<commit-sha7>"
// Локальная сборка без ldflags оставляет "dev".
var version = "dev"
func main() { func main() {
cfg := flag.String("config", "", "путь к config.yaml (по умолчанию — CWD/config.yaml)") cfg := flag.String("config", "", "путь к config.yaml (по умолчанию — CWD/config.yaml)")
version := flag.Bool("version", false, "показать версию и выйти") versionFlag := flag.Bool("version", false, "показать версию и выйти")
flag.Parse() flag.Parse()
if *version { if *versionFlag {
fmt.Println("ratatoskr-go dev (переезд с Ratatoskr Python)") fmt.Println("ratatoskr-go " + version)
return return
} }
a, err := app.New(*cfg) a, err := app.New(*cfg, version)
if err != nil { if err != nil {
log.Fatalf("app init: %v", err) 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: запущен") log.Print("ratatoskr: запущен")
if err := a.Run(context.Background()); err != nil { if err := a.Run(context.Background()); err != nil {
log.Fatalf("app run: %v", err) log.Fatalf("app run: %v", err)

View File

@@ -18,6 +18,13 @@ telegram:
# base_url: "http://gitea.hal9000.home" # базовый URL git-хоста (обязательно для клонирования) # base_url: "http://gitea.hal9000.home" # базовый URL git-хоста (обязательно для клонирования)
# token: "${GIT_TOKEN}" # токен для приватных репозиториев # token: "${GIT_TOKEN}" # токен для приватных репозиториев
# update (авто-обновление бинаря из Gitea Packages)
# Авто-режим только проверяет и уведомляет владельца; замена — по команде /update.
# enabled: false # включить фоновую проверку обновления
# owner: "kamelion" # владелец пакета в Gitea (UPDATE_OWNER)
# package: "ratatoskr" # имя пакета-хранилища (generic)
# check_interval: "24h" # как часто проверять наличие обновления
# chat: # chat:
# poll_interval: "30s" # poll_interval: "30s"

View File

@@ -21,6 +21,7 @@ import (
"github.com/kamelion/ratatoskr-go/internal/core" "github.com/kamelion/ratatoskr-go/internal/core"
"github.com/kamelion/ratatoskr-go/internal/opencode" "github.com/kamelion/ratatoskr-go/internal/opencode"
"github.com/kamelion/ratatoskr-go/internal/storage" "github.com/kamelion/ratatoskr-go/internal/storage"
"github.com/kamelion/ratatoskr-go/internal/update"
"github.com/kamelion/ratatoskr-go/internal/worker" "github.com/kamelion/ratatoskr-go/internal/worker"
) )
@@ -31,12 +32,13 @@ type App struct {
Router *chat.Router Router *chat.Router
CoreCtx *core.Core CoreCtx *core.Core
Worker *worker.Worker Worker *worker.Worker
Updater *update.Updater
tg *telegram.Channel // сохранена для Run tg *telegram.Channel // сохранена для Run
} }
// New читает конфиг и собирает все зависимости. // New читает конфиг и собирает все зависимости.
// Не запускает подсистемы (Run). // Не запускает подсистемы (Run).
func New(configPath string) (*App, error) { func New(configPath, version string) (*App, error) {
cfg, err := config.Load(configPath) cfg, err := config.Load(configPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %v", ErrConfig, err) return nil, fmt.Errorf("%w: %v", ErrConfig, err)
@@ -110,6 +112,16 @@ func New(configPath string) (*App, error) {
} }
a.Router = router a.Router = router
a.Worker = w 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 return a, nil
} }
@@ -131,7 +143,10 @@ func (a *App) Run(ctx context.Context) error {
// Worker: poll-цикл (неблокирующий — стартует свою горутину) // Worker: poll-цикл (неблокирующий — стартует свою горутину)
a.Worker.Start(ctx) a.Worker.Start(ctx)
log.Print("app: worker started") log.Printf("app: worker started")
// Авто-проверка обновления (только уведомление владельца; замена — по /update)
a.startAutoCheck(ctx)
// Ожидание сигнала или фатальной ошибки Telegram // Ожидание сигнала или фатальной ошибки Telegram
sigCh := make(chan os.Signal, 1) 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) 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)) task, err := a.Store.GetActiveTaskByChatID(ctx, string(uid))
if err != nil && !errors.Is(err, storage.ErrNotFound) { if err != nil && !errors.Is(err, storage.ErrNotFound) {
log.Printf("app: get active task: %v", err) 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 и распаковывает // ensureAgentsDir определяет каталог с агентами opencode и распаковывает
// туда встроенных агентов (go:embed). Если config_dir не задан — использует // туда встроенных агентов (go:embed). Если config_dir не задан — использует
// ./agents рядом с бинарём. Встроенные агенты перезаписываются (всегда актуальны). // ./agents рядом с бинарём. Встроенные агенты перезаписываются (всегда актуальны).

View File

@@ -20,7 +20,7 @@ func TestNew(t *testing.T) {
t.Setenv("RATATOSKR_DB", dbPath) t.Setenv("RATATOSKR_DB", dbPath)
// Загружаем без config-файла (дефолты + env) // Загружаем без config-файла (дефолты + env)
a, err := New("") a, err := New("", "dev")
if err != nil { if err != nil {
t.Fatalf("New() err = %v", err) t.Fatalf("New() err = %v", err)
} }
@@ -50,7 +50,7 @@ func TestNew_MissingToken(t *testing.T) {
t.Setenv("TG_CHAT_ID", "12345") t.Setenv("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath) t.Setenv("RATATOSKR_DB", dbPath)
_, err := New("") _, err := New("", "dev")
if err == nil { if err == nil {
t.Fatal("expected error for missing token") 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("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath) t.Setenv("RATATOSKR_DB", dbPath)
_, err := New("") _, err := New("", "dev")
if err == nil { if err == nil {
t.Fatal("expected error for invalid db path") 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("TG_CHAT_ID", "12345")
t.Setenv("RATATOSKR_DB", dbPath) t.Setenv("RATATOSKR_DB", dbPath)
a, err := New("") a, err := New("", "dev")
if err != nil { if err != nil {
t.Fatalf("New() err = %v", err) t.Fatalf("New() err = %v", err)
} }

View File

@@ -39,6 +39,7 @@ type Config struct {
Git GitCfg `yaml:"git"` Git GitCfg `yaml:"git"`
Chat ChatCfg `yaml:"chat"` Chat ChatCfg `yaml:"chat"`
Paths PathsCfg `yaml:"paths"` Paths PathsCfg `yaml:"paths"`
Update UpdateCfg `yaml:"update"`
} }
// GitCfg — источник репозиториев (для git clone). // GitCfg — источник репозиториев (для git clone).
@@ -47,6 +48,19 @@ type GitCfg struct {
Token string `yaml:"token" env:"GIT_TOKEN"` 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 { type TelegramCfg struct {
Token string `yaml:"token" env:"TG_TOKEN"` Token string `yaml:"token" env:"TG_TOKEN"`
ChatID string `yaml:"chat_id" env:"TG_CHAT_ID"` ChatID string `yaml:"chat_id" env:"TG_CHAT_ID"`

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
}