Files
mrleo1nidandClaude Opus 5.5 1d44d067e9 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>
2026-10-06 21:52:40 +03:00

187 lines
6.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("таймаут ожидания")
}