backup-master: сервер бэкапов, агент и восстановление VPS

Один бинарник backupctl:
- server: API для агентов и restore, веб-морда (один админ), SQLite,
  ретеншн GFS, heavy-части, уведомления ntfy, раздача agent.sh/restore.sh;
- backup/plan: сбор по каталогу категорий (files/systemd/xui/compose),
  горячие дампы Postgres/Mongo/MySQL, снимок x-ui.db, docker-тома,
  digest образов, проверка crontab, потоковая отправка tar.zst;
- restore: провижининг на основе vps_installer с теми же версиями пакетов,
  recipes 3x-ui/nebula/sing-box нужных версий, восстановление compose
  с заливкой дампов и закреплением образов по digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-06 21:52:40 +03:00
commit 1d44d067e9
53 files changed
+7278

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
package restore
import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/manifest"
"gitea.hsrv.site/mrleo1nid/backup-master/internal/sysx"
)
const pinFile = "compose.restore.yml"
// restoreCompose поднимает compose-проект: закрепляет образы по digest, создаёт
// недостающие external-сети, заливает дампы БД и запускает проект.
func (r *restorer) restoreCompose(c manifest.Category) error {
info := c.Compose
if info == nil {
return fmt.Errorf("нет сведений о compose-проекте")
}
if !sysx.HasCommand("docker") {
return fmt.Errorf("docker не установлен")
}
if len(info.ConfigFiles) == 0 {
return fmt.Errorf("неизвестны compose-файлы проекта")
}
args := []string{"compose", "--project-directory", info.Dir, "-p", info.Project}
for _, f := range info.ConfigFiles {
args = append(args, "-f", f)
}
if pin, err := r.writePinFile(c); err != nil {
return err
} else if pin != "" {
args = append(args, "-f", pin)
r.todo = append(r.todo, fmt.Sprintf("%s: образы закреплены по digest в %s — добавляйте `-f %s` к docker compose, иначе подтянутся новые версии",
c.Name, filepath.Join(info.Dir, pinFile), pinFile))
}
compose := func(extra ...string) error {
return sysx.Run(r.ctx, "docker", append(slices.Clone(args), extra...)...)
}
for _, n := range info.ExternalNetworks {
if _, err := sysx.Output(r.ctx, "docker", "network", "inspect", n); err == nil {
continue
}
if _, err := sysx.Output(r.ctx, "docker", "network", "create", n); err != nil {
return fmt.Errorf("сеть %s: %w", n, err)
}
r.todo = append(r.todo, fmt.Sprintf("%s: external-сеть %s создана пустой — сервис, который её предоставлял, на этом сервере не восстановлен", c.Name, n))
}
if err := compose("pull", "--ignore-buildable", "--quiet"); err != nil {
r.warn("%s: pull: %v", c.Name, err)
}
if hasBuilt(info) {
if err := compose("build"); err != nil {
return fmt.Errorf("сборка образов: %w", err)
}
}
if err := compose("create"); err != nil {
return err
}
for _, d := range c.Dumps {
if d.Service == "" {
continue
}
if err := r.restoreDB(c, d, compose, args); err != nil {
return err
}
}
if info.Running {
if err := compose("up", "-d"); err != nil {
return err
}
} else {
r.warn("%s: на старом сервере проект был остановлен — не запускаю", c.Name)
}
return nil
}
func hasBuilt(info *manifest.Compose) bool {
for _, img := range info.Images {
if img.Built {
return true
}
}
return false
}
// writePinFile пишет compose.restore.yml с образами по digest. Пустая строка — закреплять нечего.
func (r *restorer) writePinFile(c manifest.Category) (string, error) {
var services []string
for name := range c.Compose.Images {
services = append(services, name)
}
slices.Sort(services)
var b strings.Builder
b.WriteString("# Создано backup-master при восстановлении: образы закреплены по digest со старого сервера.\n")
b.WriteString("services:\n")
n := 0
for _, svc := range services {
img := c.Compose.Images[svc]
if img.Built || img.Digest == "" {
continue
}
fmt.Fprintf(&b, " %q:\n image: %q\n", svc, img.Digest)
n++
}
if n == 0 {
return "", nil
}
p := filepath.Join(c.Compose.Dir, pinFile)
return p, os.WriteFile(p, []byte(b.String()), 0o644)
}
var dbReady = map[string]string{
"postgres": `pg_isready -q -h 127.0.0.1 -U "${POSTGRES_USER:-postgres}"`,
"mongo": `(command -v mongosh >/dev/null && mongosh --quiet --host 127.0.0.1 --eval 'db.runCommand({ping:1}).ok') || mongo --quiet --host 127.0.0.1 --eval 'db.runCommand({ping:1}).ok'`,
"mysql": `mysqladmin ping -h 127.0.0.1 -uroot -p"$MYSQL_ROOT_PASSWORD" --silent`,
"mariadb": `A=$(command -v mariadb-admin || command -v mysqladmin); "$A" ping -h 127.0.0.1 -uroot -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}" --silent`,
}
var dbRestore = map[string]string{
"postgres": `pg_restore -U "${POSTGRES_USER:-postgres}" -d "${POSTGRES_DB:-${POSTGRES_USER:-postgres}}" --clean --if-exists --no-owner`,
"mongo": `if [ -n "$MONGO_INITDB_ROOT_USERNAME" ]; then exec mongorestore --quiet --archive --gzip --drop -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin; else exec mongorestore --quiet --archive --gzip --drop; fi`,
"mysql": `exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD"`,
"mariadb": `M=$(command -v mariadb || command -v mysql); exec "$M" -uroot -p"${MARIADB_ROOT_PASSWORD:-$MYSQL_ROOT_PASSWORD}"`,
}
func (r *restorer) restoreDB(c manifest.Category, d manifest.Dump, compose func(...string) error, args []string) error {
file := filepath.Join(r.staging, c.Name, "dumps", d.Name)
if !exists(file) {
return fmt.Errorf("дамп %s не найден в архиве", d.Name)
}
info("БД %s (%s): запуск", d.Service, d.Kind)
if err := compose("up", "-d", d.Service); err != nil {
return err
}
cid, err := sysx.Output(r.ctx, "docker", append(slices.Clone(args), "ps", "-q", d.Service)...)
if err != nil || cid == "" {
return fmt.Errorf("контейнер %s не найден: %v", d.Service, err)
}
cid = strings.Fields(cid)[0]
if err := r.waitDB(cid, d.Kind); err != nil {
return fmt.Errorf("БД %s не поднялась: %w", d.Service, err)
}
info("БД %s: заливка дампа %s", d.Service, d.Name)
err = sysx.FromFile(r.ctx, file, "docker", "exec", "-i", cid, "sh", "-c", dbRestore[d.Kind])
if err != nil {
if d.Kind == "postgres" {
// pg_restore возвращает 1 и на некритичных предупреждениях — данные обычно на месте
r.warn("%s: pg_restore завершился с предупреждениями, проверьте вывод выше", c.Name)
return nil
}
return fmt.Errorf("заливка дампа %s: %w", d.Name, err)
}
return nil
}
// waitDB ждёт, пока БД стабильно принимает TCP-подключения: init-скрипты образов
// поднимают временный сервер без сети, поэтому проверка через 127.0.0.1.
func (r *restorer) waitDB(cid, kind string) error {
probe := dbReady[kind]
deadline := time.Now().Add(4 * time.Minute)
okInRow := 0
for time.Now().Before(deadline) {
if _, err := sysx.Output(r.ctx, "docker", "exec", cid, "sh", "-c", probe); err == nil {
if okInRow++; okInRow >= 3 {
return nil
}
} else {
okInRow = 0
}
select {
case <-r.ctx.Done():
return r.ctx.Err()
case <-time.After(2 * time.Second):
}
}
return fmt.Errorf("таймаут ожидания")
}