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:
commit
1d44d067e9
53 files changed
+7278
No files matched your search
@@ -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("таймаут ожидания")
|
||||
}
|
||||
Reference in new issue
Block a user