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