# Быстрый цикл выпуска

**Скоуп:** `vibe:infra`

Полный редеплой через [`POST /v1/infra/servers/:id/deploy`](/docs/infra/deploy/deploy) прогоняет девять шагов подряд: остановка сервиса, очистка, скачивание архива, установка рантайма, установка зависимостей, запись `.env`, команды `preStart`, создание systemd-юнита, запуск и проверка работоспособности. Спящий сервер деплой будит сам. Это правильный путь для первого выпуска и для смены рантайма, но на каждой мелкой правке кода прогонять все девять шагов долго. Ниже — четыре приёма, которые сокращают цикл: убрать раздувание тела запроса, не переустанавливать тяжёлые зависимости, обновлять один изменившийся файл вместо всего дерева, держать данные отдельно от кода.

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

- API-ключ Вайбкод со скоупом `vibe:infra`
- BLACKHOLE-сервер с уже выпущенным приложением — приёмы ниже сокращают повторный выпуск, а не первый
- Архив сборки на рабочей машине
- Node.js 18 или новее для примеров на JavaScript

Во всех примерах `$VIBE_URL` — базовый адрес `https://vibecode.bitrix24.tech`, `$SERVER_ID` — идентификатор BLACKHOLE-сервера, `$VIBE_API_KEY` — ваш API-ключ.

## Какой приём когда применять

Приёмы независимы — берите тот, что отвечает вашей задержке, а не все подряд.

| Что мешает | Приём | Что даёт |
|---|---|---|
| Тело запроса раздувается, выгрузка идёт долго | Multipart вместо base64 | Минус ~33 % объёма, merge-режим по умолчанию |
| Каждый деплой переустанавливает тяжёлые инструменты | Тяжёлые зависимости — вне install-скрипта | Шаг `install` перестаёт упираться в свои 300 секунд |
| Изменился один файл, а прогоняются все девять шагов | Точечные обновления через `/upload` | Обновление без деплоя вообще |
| Данные лежат в `/opt/app` и стираются | Данные отдельно от кода | `cleanDeploy` перестаёт быть опасным |

Полный деплой остаётся правильным путём для первого выпуска, смены рантайма и правки `start`-команды.

Примеры ниже показывают отдельные вызовы и опираются на переменные `VIBE_URL`, `VIBE_API_KEY` и `SERVER_ID`, объявленные в первом блоке JavaScript.

## Multipart вместо base64

Встроенный `source.content` — это base64-строка архива, а base64 увеличивает объём примерно на 33 %: архив 30 МБ уходит по проводу как 40 МБ. Multipart-режим передаёт архив файлом-как-есть, без этой надбавки. Второе отличие — `cleanDeploy` в multipart-режиме по умолчанию `"false"` (merge-деплой: новые файлы кладутся поверх существующего дерева), тогда как inline-деплой из JSON по умолчанию `cleanDeploy: true` и стирает `/opt/app` перед распаковкой. Для итеративной правки merge-режим быстрее — не нужно каждый раз выкладывать полное дерево.

### cURL

```bash
tar -czf app.tar.gz -C ./my-app .
curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/deploy" \
  -H "X-Api-Key: $VIBE_API_KEY" \
  -F "file=@app.tar.gz" \
  -F "start=cd /opt/app && node server.js" \
  -F "port=3000"
```

### JavaScript

```javascript
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 form = new FormData()
form.append('file', new Blob([await readFile('app.tar.gz')]), 'app.tar.gz')
form.append('start', 'cd /opt/app && node server.js')
form.append('port', '3000')

const res = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/deploy`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY },
  body: form,
})
const body = await res.json()
if (!body.success) {
  // Отказ шага деплоя приходит кодом DEPLOY_FAILED, а имя шага — полем step.
  throw new Error(`${body.error?.step ?? 'deploy'}: ${body.error?.message}`)
}
```

Ответ перечисляет пройденные шаги:

```json
{
  "success": true,
  "data": {
    "steps": [
      { "step": "stop_existing", "status": "ok", "duration": 320 },
      { "step": "clean", "status": "ok", "duration": 180 },
      { "step": "install", "status": "ok", "duration": 21350 },
      { "step": "start", "status": "ok", "duration": 900 }
    ],
    "serviceName": "app",
    "status": "running",
    "appUrl": "https://app-b7c1e2a4f9d0.vibecode.bitrix24.tech"
  }
}
```

У шага четыре возможных состояния: `running`, `ok`, `warning` и `error`. Шаг со статусом `warning` деплой не прерывает — пояснение приходит в `stdout` этого шага.

Порт в multipart-режиме передавать обязательно — строкой (`"3000"`). Чтобы вместо merge сделать чистый деплой, добавьте `-F "cleanDeploy=true"` (в JavaScript — `form.append('cleanDeploy', 'true')`).

## Тяжёлые зависимости — вне install-скрипта

У шага `install` таймаут 300 секунд, и запускается он на каждом деплое. Долгая установка набора инструментов сборки на этом шаге либо упирается в таймаут, либо отнимает минуты у каждого деплоя. Файловая система виртуальной машины помогает этого избежать: `cleanDeploy` стирает только `extractTo` (по умолчанию `/opt/app`), а установленные рантаймы, каталог `/usr` и `/opt/data` переживают редеплой. Поэтому системный инструмент ставится один раз и остаётся на месте.

Установите тяжёлый инструмент однажды и в дальнейшем проверяйте его наличие идемпотентной строкой — она запускает установочный скрипт, только если инструмента ещё нет:

```bash
command -v pg_restore >/dev/null || bash /opt/data/install-tools.sh
```

Строку помещают в `preStart`. При первом деплое `pg_restore` не найден — скрипт отработает. На следующих деплоях инструмент уже на месте, проверка проходит мгновенно, установка не повторяется. Так каждый деплой не платит за переустановку того, что уже стоит на диске.

## Точечные обновления через /upload

Когда изменился один файл или один бандл, полный редеплой избыточен. Загрузите изменившийся архив через [`POST /v1/infra/servers/:id/upload`](/docs/infra/deploy/upload) с `extract: true` — агент распакует его прямо в рабочую директорию, минуя остальные восемь шагов деплоя. Затем перезапустите сервис через [`/exec`](/docs/infra/deploy/exec):

```bash
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" \
  -d '{
    "url": "https://example.com/dist.tar.gz",
    "path": "/opt/app/dist.tar.gz",
    "extract": true,
    "extractTo": "/opt/app"
  }'

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 restart app"}'
```

### JavaScript

```javascript
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }

const uploaded = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/upload`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    url: 'https://example.com/dist.tar.gz',
    path: '/opt/app/dist.tar.gz',
    extract: true,
    extractTo: '/opt/app',
  }),
}).then(r => r.json())
// Перезапуск вслепую, без этой проверки, поднимет прежнюю сборку и создаст
// впечатление, что правка не применилась.
if (!uploaded.success) throw new Error(uploaded.error?.message ?? 'файл не загружен')

const restarted = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/exec`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ command: 'systemctl restart app' }),
}).then(r => r.json())
if (!restarted.success) throw new Error(restarted.error?.message ?? 'сервис не перезапущен')
if (restarted.data.exitCode !== 0) throw new Error(restarted.data.stderr)
```

Ответ `/upload` подтверждает путь, размер и факт распаковки:

```json
{ "success": true, "data": { "path": "/opt/app/dist.tar.gz", "size": 4193280, "extracted": true } }
```

Если предыдущая команда на сервере ещё выполняется, перезапуск через `/exec` отклоняется:

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

Перезапуск запускайте только после успешной загрузки: если `/upload` вернул отказ, а `systemctl restart` уже ушёл, сервис поднимется на старом коде и это будет выглядеть как «деплой не применился».

Имя сервиса в команде перезапуска — то, под которым приложение задеплоено. По умолчанию это `app`, поэтому и `systemctl restart app`. Загрузка размером до 500 МБ — с ней проходит и целый бандл, и отдельный файл.

## Данные — никогда в install-шаге

Шаг `install` (и `preStart`) выполняется на каждом деплое. Если положить в него загрузку данных — вставку строк в базу, импорт справочника, накат начальных данных — эта загрузка повторится при каждом деплое, и записи задвоятся. Данные грузят в `/opt/data` один раз и под идемпотентной защитой, чтобы повторный запуск ничего не добавлял:

```bash
test -f /opt/data/.seeded || { bash /opt/data/load-seed.sh && touch /opt/data/.seeded; }
```

Каталог `/opt/data` переживает редеплой, поэтому файл-маркер `.seeded` остаётся на месте между деплоями. Первый деплой загрузит данные и поставит маркер, все следующие увидят маркер и пропустят загрузку. Команды-шаги деплоя держите для установки кода и зависимостей, а разовую загрузку данных выносите под такую защиту.

## Известные особенности

**Умолчание зависит от режима.** Умолчание `cleanDeploy` зависит от режима: у inline-деплоя из JSON это `true` и `/opt/app` стирается перед распаковкой, у multipart — `false`, то есть файлы кладутся поверх дерева. Режимы не взаимозаменяемы по последствиям.

**Потолок шага `install`.** Шаг `install` запускается на каждом деплое и ограничен 300 секундами. Тяжёлая установка либо упирается в таймаут, либо отнимает минуты у каждого выката.

**`/opt/app` не переживает чистый деплой.** Каталог `/opt/app` живёт до следующего чистого деплоя. Данные, которые должны пережить выкат, кладутся в `/opt/data`, а не в дерево приложения.

**В `/opt/data` пишет `root`, а не приложение — пока каталог не объявлен.** Все примеры выше выполняются на шагах `install` и `preStart`, то есть от `root`, и это работает как написано. Само приложение systemd запускает под непривилегированной учётной записью, которой платформа отдаёт только `extractTo`, — поэтому его собственная запись в `/opt/data` завершится ошибкой доступа. Чтобы приложение писало туда само, объявите каталог в теле деплоя:

```json
{ "dataDirs": ["/opt/data/state"] }
```

Платформа создаст каталог и передаст его учётной записи приложения на каждом деплое.

**Объявляйте отдельный подкаталог, а не сам `/opt/data`.** Владелец каталога может удалить или заменить в нём любой файл — даже те, что положил `root` и читать которые не может. В `/opt/data` по примерам выше лежат `install-tools.sh` и `load-seed.sh`, которые запускаются от `root` на каждом деплое: отдав приложению весь `/opt/data`, вы дали бы ему возможность подменить эти скрипты. Приложению — свой подкаталог (`/opt/data/state`), служебным скриптам и файлам с паролями — сам `/opt/data`, который вы не объявляете. Передаётся при этом сам каталог, а не его содержимое: файлы, положенные `root` раньше, владельца не меняют. Подробности — [«Деплой приложения»](./deploy.md).

**Потолок загрузки — 500 МБ.** Маршрут `/upload` принимает до 500 МБ на файл, всё, что больше, отклоняет.

**Отказы зависят от того, чей это вызов.** У прямого вызова `/exec`: `EXEC_TIMEOUT` — команда не уложилась в свой `timeout`, `COMMAND_TOO_LONG` — команда длиннее 10000 символов, `EXEC_BUSY` — на сервере уже идёт операция.

**У деплоя ответ другой.** Шаг, не уложившийся в свои 300 секунд, приходит как `DEPLOY_FAILED` с полем `step` — например `"step": "install"`, — а текст таймаута лежит в `message`. Ветвиться на `EXEC_TIMEOUT` после деплоя бесполезно, этот код там не приходит.

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

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

- [Полный деплой приложения](/docs/infra/deploy/deploy)
- [Загрузить файл](/docs/infra/deploy/upload)
- [Выполнить команду](./exec.md)
