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

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

Восстанавливаем дамп PostgreSQL (или другой СУБД) в базу, доступную с BLACKHOLE-сервера. Дамп — файл с выгрузкой содержимого базы, его создаёт `pg_dump`. BLACKHOLE — сервер за защищённым туннелем, к которому идут запросы [`exec`](/docs/infra/deploy/exec), [`upload`](/docs/infra/deploy/upload) и [`logs`](/docs/infra/deploy/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

```bash
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` отправляет этот файл:

```bash
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

```bash
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`.

Если установка тяжёлая и выходит за лимит `timeout` (максимум 600 секунд), запустите её фоновой задачей через `systemd-run`. Такая задача живёт в собственной группе процессов и не обрывается, когда время вызова `exec` истекает:

```bash
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:

```bash
#!/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

```bash
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

```bash
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), force-release the lock and retry.",
      "recoveryAction": "DELETE /v1/infra/servers/:id/lock",
      "autoExpiresInSeconds": 42,
      "note": "The backend lock auto-expires after ~15 minutes. The Black Hole agent also holds its own exec mutex (\u226410 min) that releases when the running command finishes or times out. If force-releasing the backend lock STILL yields EXEC_BUSY, the agent exec mutex has leaked (a detached background process is holding it open) — call POST /v1/infra/servers/:id/unstick to force-release the lock AND bounce the agent tunnel (its reconnect handler group-kills the stuck exec, freeing the mutex) with no VM reboot."
    }
  }
}
```

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

Полный перечень кодов — [Ошибки](/docs/errors).

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

**Долгая работа — в фоновую задачу.** Потолок `timeout` у `exec` — 600 секунд. По его истечении агент снимает всю группу процессов сигналом `SIGKILL`, не давая ей завершиться штатно. Команда, не уложившаяся в `timeout`, приходит отказом `EXEC_TIMEOUT`. Поэтому всё, что дольше, — включая само восстановление, — запускается фоновой задачей через `systemd-run`.

**Данные — вне `/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()}`)
```

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

- [Быстрый цикл выпуска](/docs/infra/deploy/fast-cycle)
- [Выполнение команд на сервере](/docs/infra/deploy/exec)
- [Загрузка файлов на сервер](/docs/infra/deploy/upload)
- [Чтение логов сервера](/docs/infra/deploy/logs)
