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("таймаут ожидания") }