Restore: перевод бэкапов на восстановленный сервер
Раньше восстановленная копия получала конфиг и таймер агента со старым токеном (через категории 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>
This commit is contained in:
1 parent
488abc2d34
commit
97c7051021
12 files changed
+195
-4
No files matched your search
@@ -84,6 +84,7 @@ func TestEtcCategoryExcludes(t *testing.T) {
|
||||
"/etc/nginx/sites-available/site": false,
|
||||
"/etc/security/limits.conf": false,
|
||||
"/etc/systemd/journald.conf": false,
|
||||
"/etc/backup-agent/agent.yaml": true,
|
||||
} {
|
||||
if got := skip.Match(p); got != want {
|
||||
t.Errorf("etc exclude(%s) = %v, want %v", p, got, want)
|
||||
|
||||
@@ -30,7 +30,9 @@ func (c *collector) collectUnits(s catalog.Spec) *manifest.Category {
|
||||
for _, f := range c.unowned(s.Unowned) {
|
||||
unit := filepath.Base(f)
|
||||
name := strings.TrimSuffix(strings.TrimSuffix(unit, ".service"), ".timer")
|
||||
if archive.Covers(covered, filepath.ToSlash(f)) || services[name] || services[unit] {
|
||||
// юниты самого агента не бэкапим: на новый сервер его ставит restore с новым токеном,
|
||||
// иначе восстановленная копия слала бы бэкапы со старым токеном параллельно старому серверу
|
||||
if strings.HasPrefix(unit, "backup-agent.") || archive.Covers(covered, filepath.ToSlash(f)) || services[name] || services[unit] {
|
||||
continue
|
||||
}
|
||||
mc.Paths = append(mc.Paths, f)
|
||||
|
||||
@@ -201,3 +201,10 @@ func (c *Client) DownloadPart(ctx context.Context, id int64, name string, offset
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// Takeover делает восстановленный сервер основным и возвращает новый агентский токен.
|
||||
func (c *Client) Takeover(ctx context.Context) (string, error) {
|
||||
var out struct{ Token string }
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/restore/takeover", nil, &out)
|
||||
return out.Token, err
|
||||
}
|
||||
@@ -168,6 +168,8 @@ categories:
|
||||
- /etc/machine-id
|
||||
- /etc/hostname
|
||||
- /etc/resolv.conf
|
||||
# свой агент не бэкапим: на новый сервер его ставит restore с новым токеном
|
||||
- /etc/backup-agent
|
||||
|
||||
- name: pnvpanel
|
||||
type: compose
|
||||
|
||||
@@ -40,6 +40,7 @@ type Options struct {
|
||||
Yes bool // не спрашивать подтверждение
|
||||
StagingDir string
|
||||
KeepStaging bool
|
||||
Takeover string // TakeoverAsk / TakeoverYes / TakeoverNo
|
||||
}
|
||||
|
||||
type restorer struct {
|
||||
@@ -156,6 +157,7 @@ func Run(ctx context.Context, opts Options) error {
|
||||
failed = append(failed, c.Name)
|
||||
}
|
||||
}
|
||||
r.takeover()
|
||||
r.finish(failed)
|
||||
if len(failed) > 0 {
|
||||
return fmt.Errorf("с ошибками восстановлены категории: %s", strings.Join(failed, ", "))
|
||||
@@ -193,6 +195,17 @@ func (r *restorer) summary(rm *api.RestoreManifest) {
|
||||
info("части: %s", strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
// confirmDefaultYes — вопрос, где пустой ответ означает «да».
|
||||
func confirmDefaultYes(q string) bool {
|
||||
fmt.Printf("\n%s? [Y/n]: ", q)
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "", "y", "yes", "д", "да":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func confirm(q string) bool {
|
||||
fmt.Printf("\n%s? [y/N]: ", q)
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package restore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx"
|
||||
)
|
||||
|
||||
const (
|
||||
agentConfigPath = "/etc/backup-agent/agent.yaml"
|
||||
|
||||
agentService = `[Unit]
|
||||
Description=backup-master agent
|
||||
Wants=network-online.target
|
||||
After=network-online.target docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/backupctl backup
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
TimeoutStartSec=6h
|
||||
`
|
||||
agentTimer = `[Unit]
|
||||
Description=Ежедневный бэкап backup-master
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:00:00
|
||||
RandomizedDelaySec=30m
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
`
|
||||
)
|
||||
|
||||
// Takeover — делать ли восстановленный сервер основным для бэкапов.
|
||||
const (
|
||||
TakeoverAsk = ""
|
||||
TakeoverYes = "yes"
|
||||
TakeoverNo = "no"
|
||||
)
|
||||
|
||||
// takeover переводит бэкапы клиента на этот сервер: сервер бэкапов выпускает новый
|
||||
// токен (старый отзывается — агент старого сервера получит отказ) и здесь ставится агент.
|
||||
func (r *restorer) takeover() {
|
||||
mode := r.opts.Takeover
|
||||
if mode == TakeoverAsk {
|
||||
mode = TakeoverNo
|
||||
if r.opts.Yes || confirmDefaultYes("Сделать этот сервер основным для бэкапов? Агент старого сервера перестанет их присылать") {
|
||||
mode = TakeoverYes
|
||||
}
|
||||
}
|
||||
step("Бэкапы этого сервера")
|
||||
if mode != TakeoverYes {
|
||||
info("агент не установлен — бэкапы по-прежнему присылает старый сервер (пробное восстановление)")
|
||||
r.todo = append(r.todo, "Чтобы этот сервер стал основным: в карточке клиента «Новый токен агента» и выполнить выданную команду здесь")
|
||||
return
|
||||
}
|
||||
token, err := r.client.Takeover(r.ctx)
|
||||
if err != nil {
|
||||
r.warn("не удалось перевести бэкапы на этот сервер: %v — установите агента вручную (карточка клиента → «Новый токен агента»)", err)
|
||||
return
|
||||
}
|
||||
if err := installAgent(r.ctx, r.opts.Server, token); err != nil {
|
||||
r.warn("агент не установлен: %v — токен уже перевыпущен, установите агента вручную (карточка клиента → «Новый токен агента»)", err)
|
||||
return
|
||||
}
|
||||
info("агент установлен, бэкапы теперь присылает этот сервер; токен старого сервера отозван")
|
||||
r.todo = append(r.todo,
|
||||
"Старый сервер больше не может слать бэкапы (токен отозван). При выводе из работы: sudo systemctl disable --now backup-agent.timer",
|
||||
"Первый бэкап с нового сервера сразу: sudo systemctl start backup-agent")
|
||||
}
|
||||
|
||||
func installAgent(ctx context.Context, server, token string) error {
|
||||
if err := os.MkdirAll("/etc/backup-agent", 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := fmt.Sprintf("server: %s\ntoken: %s\nspool_dir: /var/tmp/backup-agent\n", strings.TrimRight(server, "/"), token)
|
||||
if err := os.WriteFile(agentConfigPath, []byte(cfg), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
for path, body := range map[string]string{
|
||||
"/etc/systemd/system/backup-agent.service": agentService,
|
||||
"/etc/systemd/system/backup-agent.timer": agentTimer,
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := sysx.Run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||
return err
|
||||
}
|
||||
return sysx.Run(ctx, "systemctl", "enable", "--now", "backup-agent.timer")
|
||||
}
|
||||
@@ -440,3 +440,22 @@ func (s *Server) servePart(w http.ResponseWriter, r *http.Request, b *Backup, na
|
||||
filepath.Base(filepath.Dir(b.Dir)), b.ID, name))
|
||||
http.ServeContent(w, r, "", fi.ModTime(), f)
|
||||
}
|
||||
|
||||
// apiTakeover делает восстановленный сервер основным: выпускает клиенту новый
|
||||
// агентский токен. Старый токен перестаёт работать, поэтому агент старого сервера,
|
||||
// если тот ещё жив, получает отказ и больше не шлёт бэкапы.
|
||||
func (s *Server) apiTakeover(w http.ResponseWriter, r *http.Request) {
|
||||
c := clientFrom(r)
|
||||
tok := newToken()
|
||||
if err := s.st.SetClientToken(c.ID, hashToken(tok)); err != nil {
|
||||
jsonErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("%s: бэкапы переведены на восстановленный сервер, старый токен отозван", c.Domain)
|
||||
s.notify(notification{
|
||||
Title: "Сервер восстановлен: " + c.Domain,
|
||||
Msg: "Бэкапы теперь присылает восстановленный сервер, токен старого отозван.",
|
||||
Tags: "white_check_mark", Link: s.clientLink(c.ID),
|
||||
})
|
||||
jsonResp(w, http.StatusOK, map[string]string{"token": tok})
|
||||
}
|
||||
@@ -86,6 +86,7 @@ func (s *Server) Handler() http.Handler {
|
||||
// API восстановления
|
||||
mux.HandleFunc("GET /api/v1/restore/manifest", s.restoreAuth(s.apiRestoreManifest))
|
||||
mux.HandleFunc("GET /api/v1/restore/{id}/parts/{name}", s.restoreAuth(s.apiRestorePart))
|
||||
mux.HandleFunc("POST /api/v1/restore/takeover", s.restoreAuth(s.apiTakeover))
|
||||
|
||||
// публичное
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) })
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
|
||||
)
|
||||
@@ -110,3 +111,34 @@ func TestStoreMigration(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -291,7 +291,7 @@ func (s *Server) webClientRestoreToken(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, r, "token", map[string]any{
|
||||
"Client": c,
|
||||
"Title": "Восстановление " + c.Domain,
|
||||
"Note": "Токен действует 24 часа и даёт только чтение бэкапов этого клиента. Выполните на новом сервере (Ubuntu 24.04, от root или пользователя с sudo):",
|
||||
"Note": "Токен действует 24 часа и даёт только чтение бэкапов этого клиента. Выполните на новом сервере (Ubuntu 24.04, от root или пользователя с sudo). В конце restore спросит, сделать ли новый сервер основным: тогда токен старого отзывается и бэкапы шлёт только новый. Для пробного восстановления добавьте --no-takeover.",
|
||||
"Cmd": cmd,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user