Files
mrleo1nidandClaude Sonnet 5.5 09661bf22c
Release / release (push) Successful in 2m9s
CI / test (push) Successful in 2m11s
Restore: автозамена minio/minio и minio/mc на pgsty/silo и pgsty/mc
Образы minio удалены с Docker Hub, Stoat перешёл на silo (drop-in).
Замена идёт через compose.restore.yml, исходные compose-файлы не трогаются.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 02:20:38 +03:00

255 lines
9.8 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)
r.pullFallback(c, compose)
}
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) {
return r.writePinFileSkip(c, nil)
}
// pullFallback вызывается, когда общий pull не удался (например, образ по digest
// удалён из реестра — так случилось с minio/minio на Docker Hub). Тянет сервисы
// по одному; для тех, что не нашлись по digest, снимает закрепление и пробует
// тег из исходного compose-файла.
func (r *restorer) pullFallback(c manifest.Category, compose func(...string) error) {
var failed []string
for svc, img := range c.Compose.Images {
if img.Built {
continue
}
if err := compose("pull", "--quiet", svc); err != nil {
failed = append(failed, svc)
}
}
if len(failed) == 0 {
return
}
slices.Sort(failed)
skip := map[string]bool{}
for _, svc := range failed {
skip[svc] = true
}
if _, err := r.writePinFileSkip(c, skip); err != nil {
r.warn("%s: не удалось переписать %s: %v", c.Name, pinFile, err)
return
}
for _, svc := range failed {
if err := compose("pull", "--quiet", svc); err != nil {
r.warn("%s: образ сервиса %s недоступен ни по digest, ни по тегу: %v", c.Name, svc, err)
r.todo = append(r.todo, fmt.Sprintf("%s: образ сервиса %s недоступен в реестре (возможно, удалён или переехал, напр. minio/minio → quay.io/minio/minio). Поправьте image в compose-файле и выполните `docker compose up -d`", c.Name, svc))
continue
}
r.warn("%s: сервис %s: образ по digest недоступен, взят по тегу из compose-файла (версия может отличаться)", c.Name, svc)
}
}
// removedImages — образы, исчезнувшие из реестров, и их drop-in замены.
// minio/* убраны с Docker Hub; Stoat перешёл на форк silo (stoatchat/self-hosted#306),
// который сам мигрирует каталог данных minio.
var removedImages = map[string]string{
"minio/minio": "docker.io/pgsty/silo",
"minio/mc": "docker.io/pgsty/mc",
}
func replacedImage(ref string) (string, bool) {
repo, _, _ := strings.Cut(ref, "@")
repo, _, _ = strings.Cut(repo, ":")
repo = strings.TrimPrefix(repo, "docker.io/")
repl, ok := removedImages[repo]
return repl, ok
}
func (r *restorer) writePinFileSkip(c manifest.Category, skip map[string]bool) (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 == "" || skip[svc] {
continue
}
ref := img.Digest
if repl, ok := replacedImage(ref); ok {
if skip == nil {
r.warn("%s: образ %s удалён из реестра, сервис %s переведён на %s", c.Name, ref, svc, repl)
}
ref = repl
}
fmt.Fprintf(&b, " %q:\n image: %q\n", svc, ref)
n++
}
p := filepath.Join(c.Compose.Dir, pinFile)
if n == 0 {
if skip != nil {
// файл уже подключён через -f: оставляем валидный пустой
return p, os.WriteFile(p, []byte("services: {}\n"), 0o644)
}
return "", nil
}
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("таймаут ожидания")
}