Агенты подхватывают изменения с сервера: каталог и самообновление
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

+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
}