Раньше восстановленная копия получала конфиг и таймер агента со старым токеном (через категории etc и units), и бэкапы шли с двух серверов одновременно. - конфиг и юниты агента больше не бэкапятся - в конце restore спрашивает, сделать ли сервер основным: сервер бэкапов выпускает новый токен (POST /api/v1/restore/takeover), restore ставит агента; токен старого сервера отозван — его агент получает отказ - --takeover / --no-takeover; без них — вопрос (с --yes — да) - уведомление «Сервер восстановлен» в ntfy/Telegram Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
5.4 KiB
Go
145 lines
5.4 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
|
|
)
|
|
|
|
func TestAgentConfigCatalogAndUpdate(t *testing.T) {
|
|
dist := t.TempDir()
|
|
bin := []byte("fake-backupctl-binary")
|
|
if err := os.WriteFile(filepath.Join(dist, "backupctl-linux-amd64"), bin, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, err := New(Config{DataDir: t.TempDir(), DistDir: dist, Version: "v0.6.0"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
tok := newToken()
|
|
id, err := s.st.CreateClient("x", "x.example.com", hashToken(tok))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, _ := s.st.GetClient(id)
|
|
if !c.AutoUpdate {
|
|
t.Fatal("автообновление должно быть включено по умолчанию")
|
|
}
|
|
c.ConfigYAML = "categories:\n - {name: scripts, disabled: true}\n"
|
|
if err := s.st.UpdateClientSettings(c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ts := newTestHTTP(t, s)
|
|
var cfg AgentConfigResponse
|
|
decode(t, call(t, "GET", ts+"/api/v1/agent/config?arch=amd64&version=v0.5.0", tok, nil), &cfg)
|
|
|
|
cat, err := catalog.Parse([]byte(cfg.CatalogYAML))
|
|
if err != nil || cat.Validate() != nil {
|
|
t.Fatalf("каталог не читается: %v", err)
|
|
}
|
|
if cat.Find("xui") == nil || !cat.Find("scripts").Disabled {
|
|
t.Fatal("в каталоге нет встроенных категорий или переопределений клиента")
|
|
}
|
|
sum := sha256.Sum256(bin)
|
|
if cfg.Update == nil || cfg.Update.Version != "v0.6.0" || cfg.Update.Path != "/dl/backupctl-linux-amd64" || cfg.Update.SHA256 != hex.EncodeToString(sum[:]) {
|
|
t.Fatalf("update: %+v", cfg.Update)
|
|
}
|
|
if c, _ := s.st.GetClient(id); c.AgentVersion != "v0.5.0" {
|
|
t.Fatalf("версия агента не записана: %q", c.AgentVersion)
|
|
}
|
|
|
|
// актуальная версия, неизвестная архитектура, выключенное автообновление — без обновления
|
|
for _, q := range []string{"arch=amd64&version=v0.6.0", "arch=riscv64&version=v0.5.0"} {
|
|
cfg = AgentConfigResponse{}
|
|
decode(t, call(t, "GET", ts+"/api/v1/agent/config?"+q, tok, nil), &cfg)
|
|
if cfg.Update != nil {
|
|
t.Fatalf("%s: обновление не ожидалось", q)
|
|
}
|
|
}
|
|
c, _ = s.st.GetClient(id)
|
|
c.AutoUpdate = false
|
|
_ = s.st.UpdateClientSettings(c)
|
|
cfg = AgentConfigResponse{}
|
|
decode(t, call(t, "GET", ts+"/api/v1/agent/config?arch=amd64&version=v0.5.0", tok, nil), &cfg)
|
|
if cfg.Update != nil {
|
|
t.Fatal("автообновление выключено, а обновление предложено")
|
|
}
|
|
}
|
|
|
|
// Базы первых релизов без новых колонок мигрируются при открытии.
|
|
func TestStoreMigration(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "old.db")
|
|
db, err := sql.Open("sqlite", "file:"+path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = db.Exec(`CREATE TABLE clients (id INTEGER PRIMARY KEY, name TEXT NOT NULL, domain TEXT NOT NULL UNIQUE,
|
|
token_hash TEXT NOT NULL UNIQUE, config_yaml TEXT NOT NULL DEFAULT '', keep_daily INTEGER NOT NULL DEFAULT 7,
|
|
keep_weekly INTEGER NOT NULL DEFAULT 4, keep_monthly INTEGER NOT NULL DEFAULT 6, heavy_keep INTEGER NOT NULL DEFAULT 1,
|
|
heavy_every_days INTEGER NOT NULL DEFAULT 7, stale_hours INTEGER NOT NULL DEFAULT 26, created_at INTEGER NOT NULL,
|
|
last_backup_at INTEGER NOT NULL DEFAULT 0, last_heavy_at INTEGER NOT NULL DEFAULT 0, last_seen_at INTEGER NOT NULL DEFAULT 0,
|
|
last_error TEXT NOT NULL DEFAULT '', last_error_at INTEGER NOT NULL DEFAULT 0, stale_notified INTEGER NOT NULL DEFAULT 0,
|
|
disabled INTEGER NOT NULL DEFAULT 0);
|
|
INSERT INTO clients (name, domain, token_hash, created_at) VALUES ('a', 'a.example.com', 'h', 1);`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.Close()
|
|
|
|
st, err := OpenStore(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
cs, err := st.ListClients()
|
|
if err != nil || len(cs) != 1 || !cs[0].AutoUpdate {
|
|
t.Fatalf("после миграции: %v %+v", err, cs)
|
|
}
|
|
// повторное открытие не падает на уже добавленных колонках
|
|
st.Close()
|
|
if st2, err := OpenStore(path); err != nil {
|
|
t.Fatal(err)
|
|
} else {
|
|
st2.Close()
|
|
}
|
|
}
|
|
|
|
func TestTakeoverRevokesOldAgent(t *testing.T) {
|
|
s, ts := newTestServer(t)
|
|
oldTok := newToken()
|
|
id, err := s.st.CreateClient("x", "x.example.com", hashToken(oldTok))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r := call(t, "GET", ts.URL+"/api/v1/agent/config", oldTok, nil); r.StatusCode != 200 {
|
|
t.Fatalf("старый токен до takeover: %d", r.StatusCode)
|
|
}
|
|
// агентский токен takeover не делает
|
|
if r := call(t, "POST", ts.URL+"/api/v1/restore/takeover", oldTok, nil); r.StatusCode != 401 {
|
|
t.Fatalf("takeover агентским токеном: %d", r.StatusCode)
|
|
}
|
|
rt := newToken()
|
|
if err := s.st.CreateRestoreToken(hashToken(rt), id, time.Hour); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out struct{ Token string }
|
|
decode(t, call(t, "POST", ts.URL+"/api/v1/restore/takeover", rt, nil), &out)
|
|
if out.Token == "" || out.Token == oldTok {
|
|
t.Fatalf("новый токен: %q", out.Token)
|
|
}
|
|
if r := call(t, "GET", ts.URL+"/api/v1/agent/config", oldTok, nil); r.StatusCode != 401 {
|
|
t.Fatalf("старый сервер после takeover всё ещё принят: %d", r.StatusCode)
|
|
}
|
|
if r := call(t, "GET", ts.URL+"/api/v1/agent/config", out.Token, nil); r.StatusCode != 200 {
|
|
t.Fatalf("новый токен: %d", r.StatusCode)
|
|
}
|
|
}
|