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
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:
@@ -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}'"
|
||||
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}"
|
||||
3
Makefile
3
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
|
||||
|
||||
|
||||
@@ -14,17 +14,22 @@ import (
|
||||
"github.com/kamelion/ratatoskr-go/internal/app"
|
||||
)
|
||||
|
||||
// version — версия бинаря, вшивается при сборке:
|
||||
// go build -ldflags "-X main.version=<commit-sha7>"
|
||||
// Локальная сборка без 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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 рядом с бинарём. Встроенные агенты перезаписываются (всегда актуальны).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
309
internal/update/update.go
Normal file
309
internal/update/update.go
Normal 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
|
||||
}
|
||||
222
internal/update/update_test.go
Normal file
222
internal/update/update_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user