Агенты подхватывают изменения с сервера: каталог и самообновление
CI / test (push) Successful in 1m29s
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:
1 parent
1947b26913
commit
488abc2d34
21 files changed
+674
-51
No files matched your search
@@ -0,0 +1,9 @@
|
||||
//go:build !unix
|
||||
|
||||
package agent
|
||||
|
||||
import "errors"
|
||||
|
||||
func reexec(string, []string, []string) error {
|
||||
return errors.New("самообновление поддерживается только на Linux")
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user