Агенты подхватывают изменения с сервера: каталог и самообновление
CI / test (push) Successful in 1m29s

- сервер отдаёт агенту итоговый каталог (встроенный каталог сервера +
  переопределения клиента); если каталог не подходит версии агента —
  встроенный каталог агента и предупреждение
- самообновление перед бэкапом: версия сервера новее → скачивание с того же
  сервера (только /dl/...), проверка sha256 и запуска новой версии, подмена
  с откатом, перезапуск через exec; при ошибке бэкап идёт текущей версией
- галочка «Автообновление» у клиента (по умолчанию включена), версия агента
  в списке и карточке, отставшие подсвечены; версия сервера в шапке
- миграция существующей базы: колонки auto_update и agent_version
- backupctl backup --no-update

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-17 15:27:31 +03:00
1 parent 1947b26913
commit 488abc2d34
21 files changed
+674 -51

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
//go:build !unix
package agent
import "errors"
func reexec(string, []string, []string) error {
return errors.New("самообновление поддерживается только на Linux")
}
+10
View File
@@ -0,0 +1,10 @@
//go:build unix
package agent
import "syscall"
// reexec заменяет текущий процесс новым бинарником (тот же PID, systemd не замечает).
func reexec(exe string, args, env []string) error {
return syscall.Exec(exe, args, env)
}
+72 -23
View File
@@ -11,6 +11,7 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"strings"
"time"
@@ -27,43 +28,73 @@ type Options struct {
Output string
// ForceHeavy — включить heavy-части независимо от расписания.
ForceHeavy bool
// NoUpdate — не обновлять агента, даже если сервер предлагает.
NoUpdate bool
}
// prepare загружает конфиг, получает переопределения с сервера и собирает план.
func prepare(ctx context.Context, opts Options, spool string) (*Plan, *api.Client, error) {
// session — подключение к серверу и его ответ для одного запуска агента.
type session struct {
cfg *Config
client *api.Client
remote *api.AgentConfig
}
// connect читает конфиг и спрашивает у сервера настройки (каталог, обновление).
func connect(ctx context.Context, opts Options) (*session, error) {
cfg, err := LoadConfig(opts.ConfigPath)
if err != nil {
return nil, nil, err
return nil, err
}
client := api.NewClient(cfg.Server, cfg.Token)
remote, err := client.AgentConfig(ctx)
remote, err := client.AgentConfig(ctx, runtime.GOARCH, opts.AgentVersion)
if err != nil {
return nil, nil, fmt.Errorf("конфиг с сервера: %w", err)
return nil, fmt.Errorf("конфиг с сервера: %w", err)
}
return &session{cfg: cfg, client: client, remote: remote}, nil
}
// catalogFor выбирает каталог: итоговый с сервера, а если его нет (старый сервер)
// или он не читается этой версией агента — встроенный + переопределения клиента.
func catalogFor(remote *api.AgentConfig) (*catalog.Config, []string, error) {
var warns []string
if strings.TrimSpace(remote.CatalogYAML) != "" {
cat, err := catalog.Parse([]byte(remote.CatalogYAML))
if err == nil {
err = cat.Validate()
}
if err == nil {
return cat, nil, nil
}
warns = append(warns, fmt.Sprintf("каталог с сервера не подошёл этой версии агента (%v) — использую встроенный", err))
}
cat, err := catalog.Default()
if err != nil {
return nil, nil, err
return nil, warns, err
}
if strings.TrimSpace(remote.ConfigYAML) != "" {
over, err := catalog.Parse([]byte(remote.ConfigYAML))
if err != nil {
return nil, nil, fmt.Errorf("конфиг клиента с сервера: %w", err)
return nil, warns, fmt.Errorf("конфиг клиента с сервера: %w", err)
}
cat = catalog.Merge(cat, over)
}
if err := cat.Validate(); err != nil {
return nil, nil, err
}
if spool == "" {
spool = cfg.SpoolDir
}
plan, err := Collect(ctx, cat, spool, remote.HeavyDue || opts.ForceHeavy)
return cat, warns, cat.Validate()
}
// prepare собирает план бэкапа по каталогу.
func (s *session) prepare(ctx context.Context, opts Options, spool string, warns []string) (*Plan, error) {
cat, catWarns, err := catalogFor(s.remote)
if err != nil {
return nil, nil, err
return nil, err
}
plan.Manifest.Domain = remote.Domain
plan, err := Collect(ctx, cat, spool, s.remote.HeavyDue || opts.ForceHeavy)
if err != nil {
return nil, err
}
plan.Manifest.Domain = s.remote.Domain
plan.Manifest.AgentVersion = opts.AgentVersion
return plan, client, nil
plan.Manifest.Warnings = append(append(warns, catWarns...), plan.Manifest.Warnings...)
return plan, nil
}
func newSpool(base string) (string, error) {
@@ -73,20 +104,24 @@ func newSpool(base string) (string, error) {
// PrintPlan выводит, что попадёт в бэкап. Дампы БД при этом реально выполняются (во временный каталог).
func PrintPlan(ctx context.Context, opts Options, w io.Writer) error {
cfg, err := LoadConfig(opts.ConfigPath)
sess, err := connect(ctx, opts)
if err != nil {
return err
}
spool, err := newSpool(cfg.SpoolDir)
spool, err := newSpool(sess.cfg.SpoolDir)
if err != nil {
return err
}
defer os.RemoveAll(spool)
plan, _, err := prepare(ctx, opts, spool)
plan, err := sess.prepare(ctx, opts, spool, nil)
if err != nil {
return err
}
m := &plan.Manifest
fmt.Fprintf(w, "Агент %s, сервер %s\n", opts.AgentVersion, sess.remote.ServerVersion)
if u := sess.remote.Update; u != nil {
fmt.Fprintf(w, "Доступно обновление агента до %s — установится при следующем бэкапе\n", u.Version)
}
fmt.Fprintf(w, "Домен: %s, hostname: %s, %s (%s)\n", m.Domain, m.Hostname, m.OS.PrettyName, m.OS.Arch)
if len(m.Packages) > 0 {
fmt.Fprintf(w, "Пакеты:")
@@ -161,18 +196,32 @@ func Backup(ctx context.Context, opts Options) (err error) {
}
defer unlock()
cfg, err := LoadConfig(opts.ConfigPath)
sess, err := connect(ctx, opts)
if err != nil {
return err
}
spool, err := newSpool(cfg.SpoolDir)
// Самообновление — до сбора: при успехе процесс перезапускается новой версией
// (exec не возвращается), при ошибке бэкап идёт текущей версией с предупреждением.
var warns []string
if u := sess.remote.Update; u != nil && !opts.NoUpdate && os.Getenv(updatedEnv) == "" {
log.Printf("обновление агента %s → %s", opts.AgentVersion, u.Version)
if err := selfUpdate(ctx, sess.client, u, opts.AgentVersion); err != nil {
log.Printf("! обновление не удалось: %v", err)
warns = append(warns, fmt.Sprintf("обновление агента до %s не удалось: %v", u.Version, err))
}
}
if from := os.Getenv(updatedEnv); from != "" {
log.Printf("агент обновлён: %s → %s", from, opts.AgentVersion)
}
client := sess.client
spool, err := newSpool(sess.cfg.SpoolDir)
if err != nil {
return err
}
defer os.RemoveAll(spool)
start := time.Now()
plan, client, err := prepare(ctx, opts, spool)
plan, err := sess.prepare(ctx, opts, spool, warns)
if err != nil {
return err
}
+106
View File
@@ -0,0 +1,106 @@
package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
)
// updatedEnv выставляется перезапущенному процессу: не обновляться повторно в том же запуске.
const updatedEnv = "BM_UPDATED_FROM"
// selfUpdate скачивает новую версию агента с того же сервера, сверяет sha256,
// подменяет бинарник (старый сохраняется для отката) и перезапускает процесс.
// При успехе не возвращается.
func selfUpdate(ctx context.Context, client *api.Client, u *api.AgentUpdate, current string) error {
// только путь на своём сервере: чужой адрес от сервера не принимаем
if !strings.HasPrefix(u.Path, "/dl/") || strings.Contains(u.Path, "..") || strings.Contains(u.Path, "://") {
return fmt.Errorf("недопустимый путь обновления %q", u.Path)
}
if len(u.SHA256) != 64 {
return errors.New("сервер не прислал sha256")
}
exe, err := os.Executable()
if err != nil {
return err
}
if exe, err = filepath.EvalSymlinks(exe); err != nil {
return err
}
dctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
body, err := client.Download(dctx, u.Path)
if err != nil {
return fmt.Errorf("скачивание: %w", err)
}
defer body.Close()
tmp := exe + ".new"
if err := writeVerified(tmp, body, u.SHA256); err != nil {
os.Remove(tmp)
return err
}
// новый бинарник должен запускаться и сообщать ожидаемую версию
if err := checkVersion(ctx, tmp, u.Version); err != nil {
os.Remove(tmp)
return err
}
old := exe + ".old"
if err := os.Rename(exe, old); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, exe); err != nil {
_ = os.Rename(old, exe)
return err
}
env := append(os.Environ(), updatedEnv+"="+current)
err = reexec(exe, os.Args, env)
// сюда попадаем, только если exec не удался — возвращаем старую версию
_ = os.Rename(old, exe)
return fmt.Errorf("перезапуск: %w", err)
}
func writeVerified(path string, r io.Reader, want string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
if err != nil {
return err
}
h := sha256.New()
if _, err := io.Copy(io.MultiWriter(f, h), r); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
if got := hex.EncodeToString(h.Sum(nil)); got != want {
return fmt.Errorf("sha256 не совпал: %s, ожидался %s", got, want)
}
return os.Chmod(path, 0o755)
}
func checkVersion(ctx context.Context, bin, want string) error {
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
out, err := exec.CommandContext(cctx, bin, "version").Output()
if err != nil {
return fmt.Errorf("новый бинарник не запускается: %w", err)
}
if !strings.Contains(string(out), want) {
return fmt.Errorf("новый бинарник сообщает версию %q, ожидалась %s", strings.TrimSpace(string(out)), want)
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
)
func TestSelfUpdateRejectsForeignPath(t *testing.T) {
for _, p := range []string{"https://evil.example/x", "/dl/../etc/passwd", "/api/v1/x"} {
err := selfUpdate(context.Background(), api.NewClient("http://127.0.0.1:1", "t"),
&api.AgentUpdate{Version: "v1.0.0", Path: p, SHA256: strings.Repeat("a", 64)}, "v0.1.0")
if err == nil || !strings.Contains(err.Error(), "недопустимый путь") {
t.Errorf("%s: %v", p, err)
}
}
}
func TestWriteVerified(t *testing.T) {
p := filepath.Join(t.TempDir(), "bin")
// sha256("abc")
good := "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
if err := writeVerified(p, strings.NewReader("abc"), good); err != nil {
t.Fatal(err)
}
if err := writeVerified(p, strings.NewReader("abd"), good); err == nil {
t.Fatal("подменённый бинарник принят")
}
if _, err := os.Stat(p); err != nil {
t.Fatal(err)
}
}
func TestCatalogFor(t *testing.T) {
cat, warns, err := catalogFor(&api.AgentConfig{CatalogYAML: "categories:\n - {name: only, type: files, paths: [/x]}\n"})
if err != nil || len(warns) != 0 || cat.Find("only") == nil || cat.Find("xui") != nil {
t.Fatalf("каталог с сервера не применён: %v %v", err, warns)
}
// каталог, который эта версия не понимает, — встроенный + переопределения и предупреждение
cat, warns, err = catalogFor(&api.AgentConfig{
CatalogYAML: "categories:\n - {name: x, type: future-type}\n",
ConfigYAML: "categories:\n - {name: scripts, disabled: true}\n",
})
if err != nil || len(warns) != 1 || cat.Find("xui") == nil || !cat.Find("scripts").Disabled {
t.Fatalf("запасной каталог: %v %v", err, warns)
}
// старый сервер без catalog_yaml
if cat, _, err := catalogFor(&api.AgentConfig{}); err != nil || cat.Find("xui") == nil {
t.Fatalf("встроенный каталог: %v", err)
}
}