Files
backup-master/internal/restore/restore.go
T
mrleo1nidandClaude Opus 5.5 97c7051021
CI / test (push) Successful in 1m36s
Release / release (push) Successful in 1m33s
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>
2026-10-17 15:29:34 +03:00

574 lines
18 KiB
Go

// Package restore восстанавливает сервер из бэкапа: базовая подготовка, установка
// тех же версий софта, раскладка файлов и данных, запуск сервисов.
package restore
import (
"bufio"
"context"
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/api"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/archive"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/catalog"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx"
)
//go:embed provision.sh
var provisionScript []byte
type Options struct {
Server string
Token string
BackupID int64
User string // пользователь с sudo на новом сервере
SkipProvision bool
SkipHeavy bool
Yes bool // не спрашивать подтверждение
StagingDir string
KeepStaging bool
Takeover string // TakeoverAsk / TakeoverYes / TakeoverNo
}
type restorer struct {
ctx context.Context
opts Options
client *api.Client
m *manifest.Manifest
backupID int64
staging string
arch string
volumes map[string]string // имя тома → mountpoint
warnings []string
todo []string
}
func (r *restorer) warn(format string, a ...any) {
msg := fmt.Sprintf(format, a...)
r.warnings = append(r.warnings, msg)
fmt.Println(" ! " + msg)
}
func step(format string, a ...any) { fmt.Printf("\n==> "+format+"\n", a...) }
func info(format string, a ...any) { fmt.Printf(" "+format+"\n", a...) }
func Run(ctx context.Context, opts Options) error {
if os.Geteuid() != 0 {
return errors.New("restore нужно запускать от root")
}
if opts.Token == "" {
return errors.New("нужен --token (команда восстановления выдаётся в веб-морде)")
}
r := &restorer{ctx: ctx, opts: opts, client: api.NewClient(opts.Server, opts.Token), volumes: map[string]string{}}
rm, err := r.client.RestoreManifest(ctx, opts.BackupID)
if err != nil {
return fmt.Errorf("манифест: %w", err)
}
var m manifest.Manifest
if err := json.Unmarshal(rm.Manifest, &m); err != nil {
return fmt.Errorf("манифест: %w", err)
}
r.m, r.backupID = &m, rm.BackupID
r.arch = sysx.DpkgArch(ctx)
r.summary(rm)
if !opts.Yes && !confirm("Восстановить этот бэкап на ТЕКУЩИЙ сервер? Файлы будут перезаписаны") {
return errors.New("отменено")
}
r.staging = opts.StagingDir
if r.staging == "" {
r.staging = "/var/tmp/backup-restore"
}
r.staging = filepath.Join(r.staging, fmt.Sprint(rm.BackupID))
if err := os.MkdirAll(r.staging, 0o700); err != nil {
return err
}
if !opts.KeepStaging {
defer os.RemoveAll(r.staging) // там дампы баз и ключи
}
if !opts.SkipProvision {
if err := r.provision(); err != nil {
return fmt.Errorf("подготовка сервера: %w", err)
}
}
r.restoreSystem()
if err := r.prepareVolumes(); err != nil {
return err
}
parts := map[string]api.PartInfo{}
for _, p := range rm.Parts {
parts[p.Name] = p
}
main, ok := parts[manifest.MainPart]
if !ok {
return errors.New("в бэкапе нет основной части")
}
step("Распаковка основной части (%s)", human(main.Size))
if err := r.extractPart(main); err != nil {
return err
}
for _, name := range m.Parts {
if name == manifest.MainPart {
continue
}
p, ok := parts[name]
switch {
case !ok:
r.warn("heavy-часть %s удалена ротацией — данные категории %s останутся пустыми", name, name)
case opts.SkipHeavy:
r.warn("heavy-часть %s пропущена (--skip-heavy)", name)
default:
step("Распаковка heavy-части %s (%s)", name, human(p.Size))
if err := r.extractPart(p); err != nil {
r.warn("heavy-часть %s: %v", name, err)
}
}
}
// heavy-категории, не попавшие в этот бэкап
for _, c := range m.Categories {
if c.HeavySkipped {
r.warn("%s: heavy-данные (%s) в этот бэкап не входили — выберите бэкап с heavy-частью, если они нужны", c.Name, strings.Join(c.Heavy, ", "))
}
}
_ = sysx.Run(ctx, "systemctl", "daemon-reload")
var failed []string
for _, c := range order(m.Categories) {
step("Категория %s (%s)", c.Name, c.Type)
if err := r.restoreCategory(c); err != nil {
r.warn("%s: %v", c.Name, err)
failed = append(failed, c.Name)
}
}
r.takeover()
r.finish(failed)
if len(failed) > 0 {
return fmt.Errorf("с ошибками восстановлены категории: %s", strings.Join(failed, ", "))
}
return nil
}
func (r *restorer) summary(rm *api.RestoreManifest) {
m := r.m
step("Бэкап #%d %s от %s", rm.BackupID, m.Domain, rm.CreatedAt.Local().Format("02.01.2006 15:04"))
info("старый сервер: %s, %s (%s)", m.Hostname, m.OS.PrettyName, m.OS.Arch)
osr := sysx.OSRelease()
info("этот сервер: %s (%s)", osr["PRETTY_NAME"], r.arch)
if m.OS.Arch != "" && r.arch != "" && m.OS.Arch != r.arch {
r.warn("архитектура отличается (%s → %s): fallback-бинарники из бэкапа не подойдут", m.OS.Arch, r.arch)
}
if osr["VERSION_ID"] != "" && m.OS.VersionID != "" && osr["VERSION_ID"] != m.OS.VersionID {
r.warn("версия ОС отличается: %s → %s", m.OS.VersionID, osr["VERSION_ID"])
}
for _, c := range m.Categories {
line := c.Name + " (" + c.Type
if c.Version != "" {
line += " " + c.Version
}
line += ")"
if c.Compose != nil {
line += " " + c.Compose.Dir
}
info("• %s", line)
}
var parts []string
for _, p := range rm.Parts {
parts = append(parts, fmt.Sprintf("%s %s", p.Name, human(p.Size)))
}
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')
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes", "д", "да":
return true
}
return false
}
func (r *restorer) hasCategory(name string) bool {
return slices.ContainsFunc(r.m.Categories, func(c manifest.Category) bool { return c.Name == name })
}
func yn(b bool) string {
if b {
return "y"
}
return "n"
}
func (r *restorer) provision() error {
step("Базовая подготовка сервера")
m := r.m
hasCompose := slices.ContainsFunc(m.Categories, func(c manifest.Category) bool { return c.Type == catalog.TypeCompose })
hostname := m.Hostname
if hostname == "" || hostname == "localhost" {
hostname = m.Domain
}
env := []string{
"NEW_HOSTNAME=" + hostname,
"INSTALL_BLOCKLIST=" + yn(m.Features.Blocklist),
"HARDEN_SSH=" + yn(m.Features.SSHHardened),
"INSTALL_DOCKER=" + yn(hasCompose || m.Packages["docker-ce"] != ""),
"DOCKER_CE_VERSION=" + m.Packages["docker-ce"],
"DOCKER_CLI_VERSION=" + m.Packages["docker-ce-cli"],
"COMPOSE_VERSION=" + m.Packages["docker-compose-plugin"],
"INSTALL_CADDY=" + yn(m.Packages["caddy"] != "" || r.hasCategory("caddy")),
"CADDY_VERSION=" + m.Packages["caddy"],
"INSTALL_TELEGRAF=" + yn(m.Packages["telegraf"] != "" || r.hasCategory("telegraf")),
"TELEGRAF_VERSION=" + m.Packages["telegraf"],
}
if len(m.SudoUsers) > 0 {
env = append(env, "DEFAULT_USER="+m.SudoUsers[0])
}
if r.opts.User != "" {
env = append(env, "NEW_USER="+r.opts.User)
}
script := filepath.Join(r.staging, "provision.sh")
if err := os.WriteFile(script, provisionScript, 0o700); err != nil {
return err
}
cmd := exec.CommandContext(r.ctx, "bash", script)
cmd.Env = append(os.Environ(), env...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
return cmd.Run()
}
// prepareVolumes создаёт docker-тома заранее (с метками compose), чтобы распаковать данные прямо в них.
func (r *restorer) prepareVolumes() error {
var need bool
for _, c := range r.m.Categories {
if len(c.Volumes) > 0 {
need = true
}
}
if !need {
return nil
}
if !sysx.HasCommand("docker") {
return errors.New("в бэкапе есть docker-тома, но docker не установлен (запустите без --skip-provision)")
}
step("Docker-тома")
for _, c := range r.m.Categories {
for _, v := range c.Volumes {
if _, err := sysx.Output(r.ctx, "docker", "volume", "inspect", v.Name); err != nil {
args := []string{"volume", "create"}
if v.Key != "" && c.Compose != nil {
args = append(args, "--label", "com.docker.compose.project="+c.Compose.Project,
"--label", "com.docker.compose.volume="+v.Key)
}
if _, err := sysx.Output(r.ctx, "docker", append(args, v.Name)...); err != nil {
return fmt.Errorf("том %s: %w", v.Name, err)
}
}
mp, err := sysx.Output(r.ctx, "docker", "volume", "inspect", "-f", "{{.Mountpoint}}", v.Name)
if err != nil {
return fmt.Errorf("том %s: %w", v.Name, err)
}
r.volumes[v.Name] = mp
info("%s → %s", v.Name, mp)
}
}
return nil
}
// route раскладывает записи архива: files → /, volumes → mountpoint тома, dumps и bin → staging.
func (r *restorer) route(name string) (string, bool) {
cat, rest, ok := strings.Cut(name, "/")
if !ok || cat == "" {
return "", false
}
kind, rest, ok := strings.Cut(rest, "/")
if !ok || rest == "" {
return "", false
}
switch kind {
case "files":
return "/" + rest, true
case "bin", "dumps":
return filepath.Join(r.staging, cat, kind, filepath.FromSlash(rest)), true
case "volumes":
vol, inner, _ := strings.Cut(rest, "/")
mp, ok := r.volumes[vol]
if !ok {
return filepath.Join(r.staging, cat, "volumes", filepath.FromSlash(rest)), true
}
return filepath.Join(mp, filepath.FromSlash(inner)), true
}
return "", false
}
func (r *restorer) extractPart(p api.PartInfo) error {
body := &resumableReader{ctx: r.ctx, client: r.client, backupID: r.backupID, part: p.Name, h: sha256.New()}
defer body.Close()
ex := &archive.Extractor{Route: r.route}
if err := ex.Extract(body); err != nil {
return err
}
// дочитываем хвост (zstd мог не запросить последние байты), чтобы сверить сумму
if _, err := io.Copy(io.Discard, body); err != nil {
return err
}
if sum := hex.EncodeToString(body.h.Sum(nil)); sum != p.SHA256 {
return fmt.Errorf("контрольная сумма части %s не совпала", p.Name)
}
for _, w := range ex.Warnings {
r.warn("%s", w)
}
info("распаковано записей: %d", ex.Files)
return nil
}
// resumableReader читает часть с сервера и при обрыве продолжает с того же байта (Range).
type resumableReader struct {
ctx context.Context
client *api.Client
backupID int64
part string
body io.ReadCloser
off int64
retries int
h hash.Hash
}
func (rr *resumableReader) Read(p []byte) (int, error) {
for {
if rr.body == nil {
b, err := rr.client.DownloadPart(rr.ctx, rr.backupID, rr.part, rr.off)
if err != nil {
if rr.retries++; rr.retries > 5 {
return 0, err
}
time.Sleep(time.Duration(rr.retries) * 5 * time.Second)
continue
}
rr.body = b
}
n, err := rr.body.Read(p)
rr.off += int64(n)
rr.h.Write(p[:n])
if err == nil || errors.Is(err, io.EOF) {
return n, err
}
rr.body.Close()
rr.body = nil
if rr.retries++; rr.retries > 5 {
return n, err
}
fmt.Printf(" ! обрыв загрузки на %s, продолжаю: %v\n", human(rr.off), err)
if n > 0 {
return n, nil
}
}
}
func (rr *resumableReader) Close() error {
if rr.body != nil {
return rr.body.Close()
}
return nil
}
func (r *restorer) restoreCategory(c manifest.Category) error {
for _, d := range c.EnsureDirs {
if err := os.MkdirAll(d, 0o755); err != nil {
return err
}
}
switch c.Type {
case catalog.TypeSystemd, catalog.TypeXUI:
if err := r.installBinary(c); err != nil {
return err
}
if err := r.restoreSQLite(c); err != nil {
return err
}
case catalog.TypeCompose:
if err := r.restoreCompose(c); err != nil {
return err
}
case catalog.TypeUnits:
_ = sysx.Run(r.ctx, "systemctl", "daemon-reload")
for i := range c.Units {
if err := r.applyService(&c.Units[i]); err != nil {
r.warn("%s: %v", c.Name, err)
}
}
}
if c.Name == "system" {
r.fixCrontabs()
}
for _, d := range c.Dumps {
if d.Kind != "cmd" {
continue
}
if d.RestoreCmd == "" {
r.warn("%s: для дампа %s не задан restore_cmd — файл не применён", c.Name, d.Name)
continue
}
file := filepath.Join(r.staging, c.Name, "dumps", d.Name)
if err := sysx.Shell(r.ctx, d.RestoreCmd, "DUMP_FILE="+file); err != nil {
return fmt.Errorf("дамп %s: %w", d.Name, err)
}
}
for _, cmd := range c.PostRestore {
if err := sysx.Shell(r.ctx, cmd); err != nil {
r.warn("%s: post_restore %q: %v", c.Name, cmd, err)
}
}
if c.Service != nil {
return r.applyService(c.Service)
}
return nil
}
// installBinary ставит версию со старого сервера, при неудаче — копию бинарника из бэкапа.
func (r *restorer) installBinary(c manifest.Category) error {
if c.Install != "" && c.Version != "" {
if rec, ok := recipes[c.Install]; ok {
info("установка %s %s", c.Install, c.Version)
err := rec(r.ctx, c.Version, r.arch)
if err == nil {
return nil
}
r.warn("%s: установка %s %s не удалась: %v", c.Name, c.Install, c.Version, err)
} else {
r.warn("%s: неизвестный recipe %q", c.Name, c.Install)
}
}
if len(c.Binaries) == 0 {
if c.Install != "" {
return fmt.Errorf("бинарник не установлен и копии в бэкапе нет")
}
return nil
}
if r.m.OS.Arch != "" && r.arch != "" && r.m.OS.Arch != r.arch {
return fmt.Errorf("копия бинарника из бэкапа под %s, а сервер %s", r.m.OS.Arch, r.arch)
}
info("использую копию бинарника из бэкапа")
return copyTree(filepath.Join(r.staging, c.Name, "bin"), "/")
}
func (r *restorer) restoreSQLite(c manifest.Category) error {
for _, d := range c.Dumps {
if d.Kind != "sqlite" || d.Target == "" {
continue
}
if c.Service != nil {
_ = sysx.Run(r.ctx, "systemctl", "stop", c.Service.Unit)
}
src := filepath.Join(r.staging, c.Name, "dumps", d.Name)
for _, suffix := range []string{"-wal", "-shm"} {
_ = os.Remove(d.Target + suffix)
}
if err := copyFile(src, d.Target, 0o600); err != nil {
return fmt.Errorf("база %s: %w", d.Target, err)
}
info("база %s восстановлена", d.Target)
}
return nil
}
func (r *restorer) applyService(s *manifest.ServiceState) error {
_ = sysx.Run(r.ctx, "systemctl", "daemon-reload")
if s.Enabled {
if err := sysx.Run(r.ctx, "systemctl", "enable", s.Unit); err != nil {
return err
}
} else {
_ = sysx.Run(r.ctx, "systemctl", "disable", s.Unit)
}
if s.Active {
if err := sysx.Run(r.ctx, "systemctl", "restart", s.Unit); err != nil {
return fmt.Errorf("%s не запустился (journalctl -u %s): %w", s.Unit, s.Unit, err)
}
info("%s запущен", s.Unit)
} else {
_ = sysx.Run(r.ctx, "systemctl", "stop", s.Unit)
info("%s оставлен остановленным, как на старом сервере", s.Unit)
}
return nil
}
// fixCrontabs выставляет crontab-файлам владельца и права, которые требует cron.
func (r *restorer) fixCrontabs() {
files, _ := filepath.Glob("/var/spool/cron/crontabs/*")
for _, f := range files {
user := filepath.Base(f)
if _, err := sysx.Output(r.ctx, "id", "-u", user); err != nil {
r.warn("crontab пользователя %s восстановлен, но такого пользователя нет — cron его не выполнит", user)
continue
}
_ = sysx.Run(r.ctx, "chown", user+":crontab", f)
_ = os.Chmod(f, 0o600)
}
if len(files) > 0 {
_ = sysx.Run(r.ctx, "systemctl", "restart", "cron")
}
}
func (r *restorer) finish(failed []string) {
step("Итог")
if len(failed) == 0 {
info("Все категории восстановлены.")
}
if len(r.warnings) > 0 {
info("Предупреждения:")
for _, w := range r.warnings {
info(" ! %s", w)
}
}
r.todo = append(r.todo,
fmt.Sprintf("Перевести DNS %s (и другие домены сервера) на IP этого сервера", r.m.Domain),
"Если это lighthouse Nebula — обновить static_host_map на остальных узлах",
"Перезагрузить сервер (iptables-legacy и обновление ядра): sudo reboot",
)
info("Осталось сделать:")
for _, t := range r.todo {
info(" • %s", t)
}
}
func human(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d Б", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cБ", float64(n)/float64(div), []rune("КМГТП")[exp])
}