Для AI-агентов: markdown этой страницы — /docs-content/recipes/db-dump-restore.md индекс документации — /llms.txt

Загрузка дампа БД на сервер

Сложность: средний | Скоупы: vibe:infra | Стек: cURL / JavaScript, psql / pg_restore

Восстанавливаем дамп PostgreSQL (или другой СУБД) в базу, доступную с BLACKHOLE-сервера. Дамп — файл с выгрузкой содержимого базы, его создаёт pg_dump. BLACKHOLE — сервер за защищённым туннелем, к которому идут запросы exec, upload и logs.

Что понадобится

  • API-ключ Вайбкод со скоупом vibe:infra
  • BLACKHOLE-сервер, с которого доступна целевая база
  • Файл дампа на рабочей машине
  • python3 для примеров на cURL или Node.js 18+ для примеров на JavaScript — ими кодируется тело запроса на загрузку
  • Клиент СУБД той же старшей версии, что и целевая база (ставится на сервер шагом 3)
  • Доступ к базе: хост, пользователь, имя базы и пароль

Во всех примерах подставьте свои значения: $VIBE_URL — базовый URL https://vibecode.bitrix24.tech, $SERVER_ID — идентификатор сервера, $VIBE_API_KEY — ваш API-ключ. Плейсхолдеры подключения к БД — DB_HOST, DB_USER, DB_NAME.

Как устроено решение

  1. Готовим каталог для данных вне /opt/app — тот очищается при чистом деплое.
  2. Кладём дамп на сервер через /upload, минуя лимит длины команды.
  3. Ставим клиент той же старшей версии, что и целевая база.
  4. Восстанавливаем фоновой задачей с остановкой приложения — одной транзакцией.
  5. Сверяем счётчики строк.

Шаг 1. Каталог для данных — вне /opt/app

Каталог /opt/app очищается при чистом деплое. Так ведёт себя деплой, когда содержимое приложения передаётся прямо в теле JSON: поле cleanDeploy там по умолчанию true. Если архив отправляется отдельным файлом, умолчание обратное. Каталог /opt/data и установленные среды выполнения повторный деплой переживают, поэтому дамп и рабочие файлы кладите в /opt/data:

cURL

Terminal
curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/exec" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": "mkdir -p /opt/data"}'

JavaScript

javascript
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
const SERVER_ID = process.env.SERVER_ID
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }

const res = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/exec`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ command: 'mkdir -p /opt/data' }),
})
const body = await res.json()
if (!body.success) throw new Error(body.error?.message ?? 'каталог не создан')

Флаг --fail-with-body у curl обязателен: без него отказ вроде EXEC_BUSY печатается в вывод, но код возврата остаётся нулевым, и следующий шаг сценария запускается вслепую.

Шаг 2. Загрузить дамп через /upload

Мегабайты base64 не помещаются в аргумент команды оболочки из-за ограничения ARG_MAX — соберите тело запроса в файл и отправьте его через --data-binary. Скрипт кодирует db.dump в base64 и складывает в upload.json, затем curl отправляет этот файл:

Terminal
python3 - <<'EOF'
import base64, json
data = base64.b64encode(open('db.dump', 'rb').read()).decode()
json.dump({'content': data, 'path': '/opt/data/db.dump', 'mode': '0644'}, open('upload.json', 'w'))
EOF

curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/upload" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  --data-binary @upload.json

JavaScript

javascript
import { readFile } from 'node:fs/promises'

const dump = await readFile('db.dump')
const res = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/upload`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    content: dump.toString('base64'),
    path: '/opt/data/db.dump',
    mode: '0644',
  }),
})
const body = await res.json()
if (!body.success) throw new Error(body.error?.message ?? 'дамп не загружен')
console.log(`загружено ${body.data.size} байт в ${body.data.path}`)

Ответ подтверждает записанный путь и размер:

JSON
{
  "success": true,
  "data": {
    "path": "/opt/data/db.dump",
    "size": 48317204,
    "extracted": false
  }
}

Поле size — размер файла на сервере в байтах, extracted показывает, распаковал ли агент архив. Сверьте size с размером локального файла: расхождение означает обрыв загрузки.

Альтернатива — поле url вместо content: агент скачает файл по ссылке сам, без base64.

Шаг 3. Инструменты той же старшей версии, что целевая БД

Восстановление падает, если старшая версия клиента не совпадает с версией целевой базы. Дамп из pg_dump версии 17 в базе версии 16 даёт ошибку класса unsupported version: формат архива 1.16 не читается, а внутри дампа встречаются метакоманды SET transaction_timeout и \restrict, которых нет в 16-й версии. Установите клиент нужной старшей версии один раз — каталог /usr переживает деплой, повторная установка при следующих деплоях не нужна:

cURL

Terminal
curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/exec" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": "apt-get install -y postgresql-client-17"}'

JavaScript

javascript
const res = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/exec`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ command: 'apt-get install -y postgresql-client-17' }),
})
const body = await res.json()
if (!body.success) throw new Error(body.error?.message ?? 'клиент не установлен')
// Ненулевой код возврата — это НЕ отказ маршрута: success остаётся true.
if (body.data.exitCode !== 0) throw new Error(body.data.stderr || 'apt-get завершился с ошибкой')

Ответ маршрута успешен и тогда, когда сама команда упала. Признак успеха команды — data.exitCode, а не success.

У синхронного и потокового exec предел timeout — 600 секунд. На STANDALONE режим async=true поддерживает до 86400 секунд: сохраните ID операции и опрашивайте её результат; после потери подтверждения найдите запуск в списке операций сервера с kind=exec до любого повтора. Ниже рецепт использует фоновую задачу через systemd-run. Она живёт в собственной группе процессов и переживает истечение времени синхронного exec:

Terminal
curl -sS -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/exec" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": "systemctl reset-failed install-pg 2>/dev/null; systemd-run --unit=install-pg /bin/bash -c \"apt-get install -y postgresql-client-17\"", "timeout": 30}'

curl -sS -H "X-Api-Key: $VIBE_API_KEY" \
  "$VIBE_URL/v1/infra/servers/$SERVER_ID/logs?service=install-pg&lines=50"

Шаг 4. Восстановление — фоновой задачей, с остановкой приложения

Восстановление большого дампа занимает минуты, а когда время вызова exec истекает, агент снимает всю группу процессов сигналом SIGKILL, не давая ей завершиться штатно. Поэтому восстановление всегда идёт фоновой задачей. Оформите его отдельным скриптом и загрузите через /upload с правами mode: "0755" — так же, как дамп в шаге 2:

Terminal
#!/bin/bash
set -euo pipefail
set -a; source /opt/data/restore.env; set +a   # файл с PGPASSWORD=…, права 0600.
                                              # set -a экспортирует переменную дочернему psql
PSQL=(psql -h DB_HOST -p 5432 -U DB_USER -d DB_NAME)

systemctl stop app               # запросы приложения держат блокировки — TRUNCATE/COPY встанут в очередь
trap 'rm -f /opt/data/restore.env; systemctl start app' EXIT  # приложение поднимется
                                 # даже если восстановление сорвётся, файл с паролем не останется

# Очистка и загрузка идут ОДНОЙ транзакцией: при любой ошибке откатывается всё
# вместе с TRUNCATE, и старые данные остаются на месте. Отдельный TRUNCATE до
# pg_restore означал бы, что сорвавшаяся загрузка оставляет базу пустой.
{
  echo 'TRUNCATE TABLE t1, t2 RESTART IDENTITY CASCADE;'
  pg_restore --data-only --no-owner --no-privileges /opt/data/db.dump
} | "${PSQL[@]}" --single-transaction -v ON_ERROR_STOP=1

echo "RESTORE COMPLETE"

Пароль базы скрипт читает из файла /opt/data/restore.env с правами 0600 — этот файл кладётся тем же /upload, что и дамп. Годится и ~/.pgpass. Через текст команды пароль не передаётся никогда — агент журналирует первые ~200 символов команды, и секрет попал бы в журнал. Приложение останавливается на время восстановления: его запросы держат блокировки, и без остановки TRUNCATE и COPY встанут в очередь.

Два нюанса pg_restore --data-only, которые всплывают на связанных таблицах:

  • Порядок таблиц — алфавитный, не по зависимостям. Данные льются в алфавитном порядке имён таблиц, поэтому дочерняя таблица может пойти раньше родительской и упасть на нарушении внешнего ключа. Решение — переупорядоченный список объектов: pg_restore --list db.dump > toc.list, переставьте строки данных так, чтобы родительские таблицы шли первыми, и восстанавливайте с pg_restore -L toc.list ….
  • Схему миграций исключите из восстановления. Инструменты работы с базой из кода — Drizzle, Prisma и подобные — держат собственную таблицу миграций в отдельной схеме, например drizzle.__drizzle_migrations. Дамп несёт её данные, а приложение при старте могло уже вставить туда свежую строку, и тогда COPY упадёт на дубликате ключа. Исключайте схему целиком: pg_restore -N drizzle …. Таблицей миграций управляет само приложение.

Остановка systemctl stop app в скрипте выше закрывает ещё одну ловушку: служба с Restart=on-failure перезапустила бы упавшее приложение в промежутке между TRUNCATE и стартом COPY, и оно успело бы записать свои строки — одной секунды достаточно. Если во время долгого восстановления возможен внешний запуск приложения (например, параллельный /deploy), усильте остановку до systemctl mask app. Тогда и снятие маски переносится в trap, иначе замаскированная служба не запустится: trap 'systemctl unmask app; systemctl start app' EXIT.

Запуск и контроль. systemd-run помещает задачу в собственную группу процессов, поэтому она переживает истечение времени exec. Перед повторным запуском под тем же именем сбросьте прежнее состояние службы через systemctl reset-failed:

cURL

Terminal
curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/exec" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": "systemctl reset-failed restore-db 2>/dev/null; systemd-run --unit=restore-db /bin/bash /opt/data/restore.sh", "timeout": 30}'

curl -sS --fail-with-body -H "X-Api-Key: $VIBE_API_KEY" \
  "$VIBE_URL/v1/infra/servers/$SERVER_ID/logs?service=restore-db&lines=50"

JavaScript

javascript
const start = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/exec`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    command: 'systemctl reset-failed restore-db 2>/dev/null; systemd-run --unit=restore-db /bin/bash /opt/data/restore.sh',
    timeout: 30,
  }),
}).then(r => r.json())
if (!start.success) throw new Error(start.error?.message ?? 'задача не запущена')

// Задача идёт в фоне — за ходом следим по журналу юнита.
const logs = await fetch(
  `${VIBE_URL}/v1/infra/servers/${SERVER_ID}/logs?service=restore-db&lines=50`,
  { headers: { 'X-Api-Key': VIBE_API_KEY } },
).then(r => r.json())
console.log(logs.data.logs)

Скрипт выше подаёт TRUNCATE и вывод pg_restore одним потоком в psql --single-transaction -v ON_ERROR_STOP=1, поэтому очистка и загрузка составляют одну транзакцию. Это защищает от двух исходов сразу. Первый — прерванная загрузка на уже очищенных таблицах: несовпадение старшей версии клиента из шага 3 обрывает pg_restore, и при отдельном TRUNCATE база осталась бы пустой без возможности отката. Второй — «задача выполнялась часами, а загрузилось 0 строк»: без ON_ERROR_STOP загрузка продолжается после первой ошибки и завершается с виду успешно.

Строка trap 'systemctl start app' EXIT возвращает приложение при любом выходе из скрипта, включая аварийный. Без неё сорвавшееся восстановление оставляет приложение остановленным.

Шаг 5. Проверить счётчики

После восстановления сверьте количество строк в таблицах. Пароль базы здесь передаётся через поле env, а не в тексте команды:

cURL

Terminal
curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/exec" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": "psql -h DB_HOST -U DB_USER -d DB_NAME -tAc \"SELECT count(*) FROM t1\"", "env": {"PGPASSWORD": "…"}, "timeout": 60}'

JavaScript

javascript
const check = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/exec`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    command: 'psql -h DB_HOST -U DB_USER -d DB_NAME -tAc "SELECT count(*) FROM t1"',
    env: { PGPASSWORD: process.env.DB_PASSWORD },
    timeout: 60,
  }),
}).then(r => r.json())
if (!check.success) throw new Error(check.error?.message ?? 'счётчик не прочитан')
if (check.data.exitCode !== 0) throw new Error(check.data.stderr)
console.log(`строк в t1: ${check.data.stdout.trim()}`)

Ограничения

Один ответ на всю команду. Синхронный вызов /exec возвращает код возврата и оба потока вывода одним ответом.

JSON
{
  "success": true,
  "data": {
    "exitCode": 0,
    "stdout": "RESTORE COMPLETE\n",
    "stderr": "",
    "duration": 4213,
    "truncated": false
  }
}

Канал занят одной командой. Пока предыдущая синхронная команда не завершилась, канал занят.

JSON
{
  "success": false,
  "error": {
    "code": "EXEC_BUSY",
    "message": "Another operation is running on this server",
    "retryable": true,
    "retryAfter": 10,
    "hint": {
      "reason": "A 'deploy' operation currently holds the lock on this server.",
      "recovery": "If the previous operation crashed or its deploy task is stuck (e.g. the backend restarted, or the server was deleted and recreated), call POST /v1/infra/servers/:id/unstick and retry: it refuses with 409 OPERATION_IN_PROGRESS whenever a lock is still OBSERVED — a live one, or one whose owner cannot be proven dead. That guard covers the PLATFORM lock only. With no lock observed the call proceeds, and it works by bouncing the agent tunnel: the reconnect handler group-kills whatever the agent is running, including a detached background job. That is precisely why it cures a leaked agent mutex, and precisely why it is not safe to fire while your own long command may still be alive. It clears an observed lock on its own only when that lock is an exec mutex that outlived its own TTL. DELETE /v1/infra/servers/:id/lock releases the backend lock UNCONDITIONALLY and is a deliberate SECOND step. An ABSENT error.hint.holder does not authorize it: that read is fail-open, so a missing holder is also what you get when the platform could not read one. Absence buys a normal retry after Retry-After — nothing more. Force-release only after you have confirmed the server is idle INDEPENDENTLY (GET /v1/infra/servers/:id plus the logs); otherwise keep retrying or escalate.",
      "recoveryAction": "POST /v1/infra/servers/:id/unstick",
      "autoExpiresInSeconds": 42,
      "note": "The backend lock auto-expires after ~15 minutes. The Black Hole agent also holds its own synchronous exec mutex (≤10 min), or a durable exec admission gate for the whole job. First read GET /v1/infra/servers/:id/operations?kind=exec and reconcile durable operations. While a durable operation is running, poll its operationId; an unknown outcome needs support and positive proof that the job stopped. POST /v1/infra/servers/:id/unstick does not clear a durable admission gate. Only when no durable job remains active and a leaked synchronous mutex is independently confirmed should you use that guarded recovery. The order lives in hint.recovery: unstick comes BEFORE any unconditional release, never after it."
    }
  }
}

Сначала прочитайте GET /v1/infra/servers/:id/operations?kind=exec и исход предыдущей операции. На STANDALONE работающая длительная задача занимает канал после освобождения платформенного лока с 202. Опрашивайте её операцию; при неизвестном исходе нужны поддержка и независимое подтверждение полной остановки. Отсутствие autoExpiresInSeconds не доказывает утечку мьютекса. Восстановление туннеля и удаление лока не снимают ограничение приёма длительной задачи. В Galaxy команды выполняются синхронно с пределом 600 секунд и общим каналом для всех приложений и выкладок; задача соседнего приложения может не попасть в ваш список операций, поэтому пустой список не доказывает простой хоста.

Поля retryable и retryAfter — машинный сигнал: повтор уместен, пауза в секундах (не больше 10 — это интервал опроса, а не срок жизни блокировки), она же приходит заголовком Retry-After. Блокировка снимается сама примерно через 15 минут; с зависшей сначала идут в гардированный POST /v1/infra/servers/:id/unstick — пока замок наблюдается, он отвечает 409, а сам собой снимает только exec-мьютекс, переживший собственный TTL, — а безусловное DELETE /v1/infra/servers/:id/lock остаётся последним шагом и только после независимо подтверждённого простоя сервера.

Полный перечень кодов — Ошибки.

Большие файлы — через /upload. Мегабайты base64 не помещаются в аргумент команды из-за лимита ARG_MAX, а команда длиннее 10000 символов отклоняется отказом COMMAND_TOO_LONG. Поэтому дамп и любые большие файлы идут через /upload, а не через exec.

Дамп крупнее 72 МБ отправляйте ссылкой. Поле content этого шага — base64 внутри тела запроса, и тело ограничено 96 МБ, то есть около 72 МБ самого дампа. Тело сверх потолка возвращает 413 INLINE_SOURCE_TOO_LARGE, и повтор того же запроса не поможет. Выложите дамп туда, откуда сервер заберёт его по HTTPS, и передайте url вместо content — по ссылке маршрут принимает до 500 МБ. Дамп можно и уменьшить: pg_dump -Fc сжимает выгрузку.

Выберите режим для долгой работы. У синхронного и потокового exec предел timeout — 600 секунд. Длительный режим async=true на STANDALONE поддерживает до 86400 секунд и сохраняет результат; опрашивайте ту же операцию и сверяйте исход после потери подтверждения до любого повтора. При истечении заданного timeout агент снимает всю группу процессов сигналом SIGKILL, не давая ей завершиться штатно. Синхронная команда, превысившая timeout, сообщает EXEC_TIMEOUT; часть работы уже могла выполниться. У длительной операции после истечения срока возможен status: "unknown". Ни один из этих исходов не доказывает, что команда не запускалась или полностью остановилась: прочитайте ту же операцию, сверьте состояние и логи и независимо подтвердите остановку до решения о повторе. В этом рецепте восстановление запускается фоновой задачей через systemd-run; это альтернатива длительному exec.

Данные — вне /opt/app. Каталог /opt/app очищается при чистом деплое, поэтому дамп и рабочие файлы живут в /opt/data. Установленные среды выполнения в /usr повторный деплой переживают, повторно ставить клиент не нужно.

Не объявляйте /opt/data каталогом данных приложения. Поле dataDirs в теле деплоя передаёт каталог учётной записи, под которой работает приложение, а владелец каталога может удалить или заменить в нём любой файл — включая restore.env с паролем и сам restore.sh, который вы запускаете от root. Для этого рецепта это прямой путь к подмене исполняемого скрипта. Нужен приложению собственный каталог состояния — объявите отдельный, например /opt/data/state. По этой же причине dataDirsRecursive для /opt/data не принимается.

Пароль — не в тексте команды. Агент журналирует первые ~200 символов команды. Пароль базы поэтому передаётся переменной окружения PGPASSWORD, файлом ~/.pgpass или полем env, но никогда текстом команды.

Откат при ошибке. Восстановление идёт одной транзакцией: при ошибке откатывается вместе с очисткой таблиц. Отдельная очистка до загрузки оставила бы базу пустой на сорвавшемся восстановлении.

Полный код

Скрипт проходит все пять шагов: готовит каталог, кладёт дамп и сценарий восстановления, запускает фоновую задачу и ждёт её завершения по журналу. Это единственный запускаемый артефакт страницы — примеры шагов выше показывают отдельные вызовы.

Скрипт очищает таблицы целевой базы. Перед первым запуском убедитесь, что DB_NAME указывает на ту базу, которую вы намерены перезаписать, и что у вас есть отдельная резервная копия.

javascript
// restore.mjs — загрузка дампа на BLACKHOLE-сервер и восстановление базы
// Расширение .mjs обязательно: скрипт использует await на верхнем уровне,
// а файл .js без "type": "module" Node читает как CommonJS и падает на разборе.
import { readFile } from 'node:fs/promises'

const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
const SERVER_ID = process.env.SERVER_ID
const DB_PASSWORD = process.env.DB_PASSWORD
if (!VIBE_API_KEY || !SERVER_ID || !DB_PASSWORD) {
  throw new Error('задайте VIBE_API_KEY, SERVER_ID и DB_PASSWORD')
}

const BASE = `${VIBE_URL}/v1/infra/servers/${SERVER_ID}`
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

// Маршрут отвечает успехом и на команду, завершившуюся с ошибкой: признак
// успеха самой команды — exitCode, а не success. Проверяются оба.
async function call(path, init) {
  const res = await fetch(`${BASE}${path}`, init)
  const body = await res.json().catch(() => null)
  if (!body?.success) throw new Error(body?.error?.message ?? `запрос отклонён (${res.status})`)
  return body.data
}

async function exec(command, timeout = 60) {
  const data = await call('/exec', {
    method: 'POST',
    headers,
    body: JSON.stringify({ command, timeout }),
  })
  if (data.exitCode !== 0) throw new Error(data.stderr || `команда завершилась кодом ${data.exitCode}`)
  return data.stdout
}

async function upload(localPath, remotePath, mode) {
  const bytes = await readFile(localPath)
  const data = await call('/upload', {
    method: 'POST',
    headers,
    body: JSON.stringify({ content: bytes.toString('base64'), path: remotePath, mode }),
  })
  // Размер на сервере сверяется с локальным: расхождение означает обрыв загрузки.
  if (data.size !== bytes.length) {
    throw new Error(`${remotePath}: загружено ${data.size} из ${bytes.length} байт`)
  }
  return data
}

// 1. Каталог вне /opt/app — тот очищается при чистом деплое
await exec('mkdir -p /opt/data')

// 2-3. Дамп, сценарий восстановления и файл с паролем. Пароль уходит
// отдельным файлом с правами 0600, а не текстом команды: агент журналирует
// первые ~200 символов команды, и секрет попал бы в журнал.
await upload('./db.dump', '/opt/data/db.dump', '0644')
await upload('./restore.sh', '/opt/data/restore.sh', '0755')
await call('/upload', {
  method: 'POST',
  headers,
  body: JSON.stringify({
    content: Buffer.from(`PGPASSWORD='${DB_PASSWORD.replace(/'/g, `'\\''`)}'\n`).toString('base64'),
    path: '/opt/data/restore.env',
    mode: '0600',
  }),
})

// 4. Фоновая задача: собственная cgroup переживает таймаут exec.
// reset-failed обязателен — иначе повторный запуск под тем же именем откажет.
await call('/exec', {
  method: 'POST',
  headers,
  body: JSON.stringify({
    command: 'systemctl reset-failed restore-db 2>/dev/null; '
      + 'systemd-run --unit=restore-db /bin/bash /opt/data/restore.sh',
    timeout: 30,
  }),
})

// 5. Ждём маркер завершения в журнале юнита
for (let i = 0; i < 120; i++) {
  await sleep(15_000)
  const { logs } = await call('/logs?service=restore-db&lines=50', {
    headers: { 'X-Api-Key': VIBE_API_KEY },
  })
  // Маршрут отдаёт logs МАССИВОМ строк, а journalctl предваряет каждую
  // отметкой времени и именем юнита. Поэтому маркер ищется вхождением
  // в склеенный текст, а не равенством элемента массива.
  const text = logs.join('\n')
  if (text.includes('RESTORE COMPLETE')) {
    console.log('восстановление завершено')
    break
  }
  if (/^.*(FATAL|ERROR:)/m.test(text)) throw new Error(`восстановление сорвалось:\n${text}`)
  if (i === 119) throw new Error('маркер завершения не появился за 30 минут')
}

const rows = await exec('psql -h DB_HOST -U DB_USER -d DB_NAME -tAc "SELECT count(*) FROM t1"')
console.log(`строк в t1: ${rows.trim()}`)

Смотрите также