# Blue-green в badmfck-api-server

Как устроена выкладка без простоя и как приложение узнаёт, работает ли оно сейчас в проде.

---

## 1. Идея в двух словах

У проекта **два слота** — `blue` и `green`. Каждый слот — это отдельная папка и отдельный pm2-процесс на своём порту. Трафик в каждый момент идёт **только в один** из них: nginx смотрит на порт активного слота.

- **Deploy** выкладывает новую версию в **неактивный** слот. Прод при этом не трогается.
- **Activate** переключает nginx на выложенный слот. Переключение мгновенное, без простоя.
- **Rollback** — это тот же activate, но на предыдущий слот. Старая версия всё ещё запущена, поэтому откат занимает секунды.

---

## 2. Что где лежит на сервере

| Что | Где | Кто меняет |
|---|---|---|
| Код слотов | `<destination>/blue`, `<destination>/green` | деплоер при deploy |
| pm2-процессы | `<name>-blue`, `<name>-green` | деплоер при deploy |
| Состояние проекта | `<projects_path>/<name>_state_.json` | деплоер при deploy / activate |
| Порт активного слота для nginx | `<nginx.config_dir>/<name>.upstream.conf` | деплоер, **переписывает** при каждом activate |
| Серверный конфиг nginx | `<nginx.config_dir>/<name>.server.conf` | деплоер создаёт **один раз**, дальше им владеет devops (SSL через certbot и т.п.) |
| Паспорт слота для приложения | `<destination>/<slot>/.bluegreen.json` (права `600`) | деплоер при deploy, пишет в **оба** слота |

Файл состояния:

```jsonc
{
  "active": "blue",          // куда сейчас идёт трафик
  "lastDeployed": "green",   // куда была последняя выкладка
  "history": [ { "slot": "green", "action": "deploy", "user": "igor", "at": 1789… } ],  // последние 200
  "statusToken": "…"         // read-only токен для приложений, см. раздел 6
}
```

Если `lastDeployed ≠ active`, значит новая версия выложена, но ещё не включена.

---

## 3. Жизненный цикл

**Первый деплой проекта:** слот `blue` создаётся **сразу активным**: `active = lastDeployed = blue`, nginx настраивается на него.

**Каждый следующий деплой:**
1. Деплоер выбирает слот, противоположный активному (active `blue` → выкладка в `green`).
2. Распаковывает архив, делает `npm ci`, пишет конфиг, запускает `<name>-green`.
3. **nginx не трогается** — трафик продолжает идти в `blue`.
4. Можно проверить `green` напрямую по его порту.

**Activate:**
1. `active` становится `green`.
2. `upstream.conf` переписывается на порт `green`.
3. Выполняется хук `on_switch`, обычно `nginx -s reload`.
4. `blue` **продолжает работать** — это и есть готовый откат.

**Rollback:** `badmfck activate blue` возвращает трафик на `blue`.

> Если в ответе activate пришло «SLOT FLIPPED BUT nginx RELOAD FAILED», состояние уже переключено, упал только reload. Перезапусти его вручную: `nginx -s reload`.

---

## 4. Настройка проекта на деплоере

Конфиг проекта в `projects_path`:

```jsonc
{
  "name": "gateway",
  "destination": "/var/node/gateway",
  "token": "…",
  "unpack": true, "install": true, "pm2": true,
  "backup": true,   // перед каждым деплоем копировать то, что будет перезаписано (раздел 5.1)
  "migrate": true,  // запускать `npm run migrate` перед стартом процесса (раздел 5.2)

  // Общие значения для обоих слотов (БД, домены, флаги)
  "config": { "DB_HOST": "…" },

  "bluegreen": {
    // Отличия слотов. Слот побеждает при совпадении ключей.
    // Порт обязателен (ключ port / PORT, регистр не важен) и у слотов должен различаться.
    "blue_config":  { "port": "8055" },
    "green_config": { "port": "8056" },

    // Выполняется после каждого переключения. Путь к бинарнику — абсолютный.
    "on_switch": ["/usr/sbin/nginx", "-s", "reload"],

    // Опционально: разные флаги pm2 для слотов
    "blue_pm2_attributes":  ["--cron-restart", "10 0 * * *"],
    "green_pm2_attributes": ["--cron-restart", "10 0 * * *"]
  },

  "nginx": {
    "config_dir": "/var/node-nginx-configs",   // nginx должен делать include /var/node-nginx-configs/*.conf
    "domain": "gateway.example.com"
  }
}
```

В `setup.json` деплоера можно указать `status_url` — адрес, по которому приложения спрашивают свой статус. Нужен **только** если деплоер не слушает `127.0.0.1` (подробнее в разделе 6).

---

## 5. CLI в проекте

`package.json` проекта:

```json
"scripts": {
  "deploy":       "badmfck deploy",
  "activate":     "badmfck activate",
  "rollback:blue":  "badmfck activate blue",
  "rollback:green": "badmfck activate green",
  "status":       "badmfck status",
  "backups":      "badmfck backups",
  "restore":      "badmfck restore"
}
```

| Команда | Что делает |
|---|---|
| `npm run deploy` | сборка и выкладка в неактивный слот |
| `npm run activate` | включить последний выложенный слот |
| `badmfck activate blue` / `green` | включить конкретный слот (rollback) |
| `npm run status` | какой слот активен, какой выложен последним, ждёт ли что-то переключения, кто и когда это делал |
| `npm run backups` | список бэкапов: версия, слот, кто и когда, размер на диске |
| `npm run restore` | восстановить бэкап: спросит, какой, и попросит подтверждение |

**Где CLI берёт конфиг.** Сначала папка `deploy/` (если там несколько конфигов, CLI спросит, какой взять), потом файл `deploy.json`. Путь можно указать явно, но через npm нужен разделитель `--`:

```bash
npm run deploy -- deploy/prod.json
npm run activate -- deploy/prod.json blue
```

---

## 5.1. Бэкапы и восстановление

Работает для любых проектов, не только blue-green. Включается флагом `"backup": true` в конфиге проекта на деплоере.

### Что происходит при деплое

Перед тем как в папку назначения что-либо попадёт, деплоер копирует **всё её содержимое** на уровень выше:

```
обычный проект   /var/node/sms        →  /var/node/2026-10-14_18-45-09-sms
blue-green слот  /var/node/gw/blue    →  /var/node/gw/2026-10-14_18-45-09-gw-blue
```

- Копия **полная, вместе с `node_modules`**, с сохранением прав и симлинков. Поэтому восстановление — обычное копирование: без сети и без `npm ci` в момент инцидента.
- Хранятся **последние 10** бэкапов проекта, более старые удаляются автоматически.
- Первый деплой в пустую папку бэкап не делает — сохранять нечего.
- **Если бэкап не удался** (кончилось место, нет прав) — **деплой прерывается, ничего не изменив**. Раз флаг включён, перезаписывать без страховочной копии нельзя.

Бэкап опознаётся по файлу метаданных внутри, а не по имени папки. Поэтому несколько проектов в одной родительской папке не путают бэкапы друг друга, даже если их имена похожи (`gateway` и `gateway-v2`).

### Восстановление

```bash
npm run backups                                    # посмотреть, что есть
npm run restore                                    # выбрать из списка, подтвердить
npm run restore -- 2026-10-13_10-00-00-gw-blue     # конкретный бэкап
badmfck restore 2026-10-13_10-00-00-gw-blue --yes  # без вопросов (CI, скрипты)
```

Что делает деплоер:
1. **Сначала бэкапит текущее состояние** — неудачное восстановление тоже можно откатить. Команда для отката печатается в конце (`undo with: …`).
2. Готовит копию рядом и **подменяет папку переименованием**: живая папка ни в какой момент не пустая и не наполовину записанная.
3. Перезапускает процесс в pm2, если у проекта `pm2: true` (для blue-green — именно процесс восстановленного слота).
4. Для blue-green пишет `restore` в историю проекта.

Восстановление берёт ту же блокировку, что и деплой, поэтому одновременно с выкладкой оно не выполнится.

### Куда уходит восстановленный код

| Проект | Результат |
|---|---|
| обычный | **сразу в проде** |
| blue-green, восстановлен **активный** слот | **сразу в проде** |
| blue-green, восстановлен **неактивный** слот | в прод не попадает; чтобы включить — `badmfck activate <слот>` |

CLI показывает это заранее и в первых двух случаях предупреждает `⚠ LIVE`. Без терминала (CI, скрипты) восстановление выполняется **только с `--yes`**.

> В blue-green деплой перезаписывает **неактивный** слот, то есть позапрошлую версию: предыдущая и так живёт во втором слоте, откат на неё — обычный `activate`. Бэкапы здесь дают историю глубже.

### О чём помнить

- **Место на диске.** Каждый бэкап — полная копия вместе с `node_modules`, ×10 на проект. Реальный размер виден в `npm run backups`.
- **Конфиг «запечён» в бэкап.** Значения из `config` подставляются при деплое, поэтому восстановленный код несёт значения на момент **того** деплоя. Если с тех пор меняли пароль БД или другой секрет — восстановленное приложение придёт со старым.
- **Проверь флаг в существующих конфигах.** `backup` в шаблоне обязательный, поэтому у проектов, созданных через API, он, скорее всего, уже стоит `true` — до появления этой функции он просто игнорировался.

### API

```
POST pckg/backups   {name}                  — список; авторизация пользователя деплоера
POST pckg/restore   {name, token, backup}   — восстановление; пользователь + токен проекта
```

Ответ восстановления: `{restored, appVersion, slot, activeSlot, live, preRestoreBackup, pm2Restarted, pm2Error}`.

---

## 5.2. Миграции БД

Флаг `"migrate": true` в конфиге проекта на деплоере. По умолчанию выключен.

### Когда запускается

```
распаковка → npm ci → подстановка config → npm run migrate → запуск в pm2
```

- **После подстановки конфига** — миграция видит настоящие доступы к БД, а не плейсхолдеры `_{{…}}_`.
- **До запуска процесса** — новый код никогда не стартует на старой схеме.
- Выполняется в папке выкладки (для blue-green — в папке слота), поэтому `.env`, загружаемый через dotenv, находится сам.
- В `package.json` проекта должен быть скрипт `migrate`. Если флаг включён, а скрипта нет — деплой падает с понятной ошибкой.
- Таймаут — 10 минут.

### Если миграция упала

Деплой останавливается, **процесс не перезапускается**, вывод миграции возвращается в `badmfck deploy` и пишется в лог деплоера. Пароли в строках подключения (`mysql://user:***@host`) маскируются.

| Проект | Состояние после ошибки |
|---|---|
| blue-green | новый слот не запущен, **активный слот не затронут** — прод работает как раньше |
| обычный | файлы на диске **уже заменены**, но старый процесс работает со старым кодом в памяти. Если он перезапустится, поднимется новый код на непромигрированной схеме. Поэтому для таких проектов держи `"backup": true` и при ошибке делай `badmfck restore` |

### ⚠️ Blue-green и общая база

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

1. **Миграции должны быть обратно совместимыми.** Старая версия обязана работать на новой схеме: добавлять колонки и таблицы можно, а удалять и переименовывать — только отдельным деплоем, когда старый код уже нигде не работает.
2. **Откат кода не откатывает схему.** Ни `badmfck activate <старый слот>`, ни `badmfck restore` не возвращают БД назад. Если нужен откат схемы — это отдельная down-миграция.

### Безопасность

При установке зависимостей скрипты пакетов отключены (`npm ci --ignore-scripts`). `migrate` — единственный скрипт из `package.json`, который деплоер запускает сам, и выполняется он с правами деплоера. Это тот же уровень доверия, что и к запуску самого приложения, но держи в голове: включая флаг, ты разрешаешь выкладываемому коду выполниться на сервере ещё до старта.

---

## 6. Приложение: «я сейчас в проде?»

### Главное понятие — `live`

У каждого процесса есть `live`: **должен ли он работать как живой экземпляр**.

| Ситуация | `live` |
|---|---|
| Проект **не** на blue-green | `true` — единственный экземпляр всегда живой |
| Blue-green, мой слот активен | `true` |
| Blue-green, мой слот в резерве (standby) | `false` |
| Ответа от деплоера ещё не было | `null` — ждать, не угадывать |

Логика, написанная через `live`, **не меняется** при переходе проекта на blue-green: сегодня она просто получает `true`.

### Основной способ — `onLiveChange`

```ts
import { APIService } from "badmfck-api-server";

APIService.onLiveChange(live => {
  if (live) startCrons();   // запустить синглтон-работу
  else      stopCrons();    // я в резерве — остановиться
});
```

- Если ответ уже известен, колбэк вызывается **сразу**. Дальше — только когда `live` **меняется** (activate, rollback). Не важно, подписался код до первой проверки или после.
- С неизвестным значением колбэк **не вызывается никогда**.
- Проект не на blue-green: колбэк вызовется **один раз** с `true`.
- Возвращает функцию отписки.

### Синхронная проверка — для горячего пути

```ts
if (APIService.isLive) { … }          // true / false / null, без I/O
const s = APIService.blueGreenState;  // полное последнее известное состояние или null
```

### Прочее

```ts
// Сырой результат прямо сейчас (может вернуть isActive: null при сбое). Кешируется на 10 с.
const s = await APIService.getBlueGreenActiveState({ force: true });

// Низкоуровневый сигнал: срабатывает при любой смене live / isActive / active
import { S_BLUEGREEN_STATE_CHANGED } from "badmfck-api-server";
S_BLUEGREEN_STATE_CHANGED.subscribe(({ current, previous }) => { … });
```

Поля состояния:

| Поле | Значение |
|---|---|
| `live` | на что ветвиться (см. выше) |
| `bluegreen` | выложено ли приложение как blue-green слот |
| `project`, `slot` | проект и мой слот |
| `active` | активный слот по данным деплоера |
| `isActive` | `slot === active` |
| `lastDeployed`, `readyToSwitch` | последний выложенный слот / ждёт ли он переключения |
| `stale` | последняя проверка не удалась, показано последнее известное значение |
| `error` | почему проверка не удалась |

### Настройка

```ts
new APIService({ …, blueGreenWatchMs: 5000 })   // по умолчанию 5000; 0 — выключить наблюдатель
```

Наблюдатель запускается сам после старта сервера. Если приложение выложено не через blue-green, он останавливается после первой проверки. Если наблюдатель выключен, `isLive` / `blueGreenState` обновляются только при ручном вызове `getBlueGreenActiveState()`.

### Как это работает под капотом

1. При деплое деплоер кладёт в папку каждого слота `.bluegreen.json`: имя проекта, слот, адрес для запроса статуса и **read-only токен**.
2. Приложение ищет этот файл вверх от папки entry-скрипта. Путь можно задать явно через env `BLUEGREEN_FILE`.
3. Приложение спрашивает деплоер `POST …/pckg/slot` по `127.0.0.1` (деплоер всегда на той же машине) и получает активный слот.
4. Деплоер читает файл состояния на каждый запрос, поэтому **activate виден сразу**, рестарт не нужен.

### Надёжность

- **Сбой деплоера не переводит приложение в резерв.** Неудачная проверка сохраняет последнее известное значение (`stale: true`) и **не** вызывает `onLiveChange`.
- **Битый `.bluegreen.json`** даёт `live: null`, колбэк не вызывается. Считать такой слот живым опасно: синглтон-работа может пойти дважды.
- Методы **никогда не бросают** исключения.
- Нагрузка: не больше 1 запроса за 5–10 с на процесс, по loopback. Запросы не пишутся в netlog деплоера и не попадают в статистику исходящих запросов приложения.

### Безопасность

- `statusToken` даёт **только** право узнать активный слот. Токен деплоя и логин/пароль деплоера в приложение не попадают.
- `.bluegreen.json` создаётся с правами `600`.
- `pckg/slot` отвечает одинаковым 401 на неизвестный проект и на неверный токен (перебором проекты не узнать). Имя проекта строго валидируется, токен сравнивается за константное время.

---

## 7. Что зависит от `live`, а что нет

**Запускать только при `live === true`:**
- кроны и планировщики;
- рассылки и уведомления;
- потребители очередей, которые должны быть в единственном экземпляре;
- фоновые задачи, пишущие общее состояние (прогрев общих кешей, агрегации).

**Не зависят от `live`:**
- HTTP-обработчики: трафик и так идёт только в активный слот через nginx, а резервный слот нужно уметь проверить по его порту;
- эндпоинты liveness / readiness / мониторинга.

**⚠️ База данных общая для обоих слотов.** Новая версия стартует и работает параллельно со старой ещё **до** переключения, а после отката старая версия снова работает с базой. Поэтому:
- миграции схемы должны быть **обратно совместимыми**: сначала добавить новое, удалить старое — отдельной выкладкой позже;
- миграцию, которую нельзя запускать дважды, выполняй только на живом слоте или отдельным шагом.

---

## 8. Переход обычного проекта на blue-green (вручную)

1. Задай в `blue_config` / `green_config` порты, **отличные** от текущего порта приложения.
2. Добавь в конфиг проекта блоки `bluegreen` и `nginx` (раздел 4).
3. Выполни первый деплой. Поднимется `<name>-blue` в `<destination>/blue`, и nginx переключится на него.
4. Проверь, что сервис отвечает через домен и что `npm run status` показывает `active: BLUE`.
5. **Останови старый процесс:** `pm2 delete <name>`, затем `pm2 save`.

> Пятый шаг обязателен. Деплоер старый процесс **не** останавливает. Над ним нет `.bluegreen.json`, поэтому он продолжит считать себя `live: true`, и синглтон-работа будет выполняться **дважды**.

Код приложения можно готовить заранее, ещё до перехода: `onLiveChange` на обычном проекте просто вернёт `true`.

---

## 9. Диагностика

| Симптом | Причина | Что делать |
|---|---|---|
| деплой падает с `Backup failed, deploy aborted` (code 54) | не удалось сделать бэкап: нет места или прав на родительскую папку | освободить место / выдать права; или временно `"backup": false` |
| деплой падает с `Migrations failed` (code 60) | ошибка в самой миграции или нет доступа к БД | смотреть вывод в ответе `badmfck deploy` или в логе деплоера; для обычного проекта при необходимости `badmfck restore` |
| `"migrate" is enabled, but package.json has no "migrate" script` | флаг включён, скрипта нет | добавить `"migrate"` в `scripts` или выключить флаг |
| `Backup not found` (code 55) при восстановлении | неверное имя или бэкап уже удалён ротацией | взять имя из `npm run backups` |
| восстановление прошло, но `pm2 RESTART FAILED` | файлы на месте, процесс не перезапустился | `pm2 restart <имя>` вручную, затем `pm2 logs` |
| `bluegreen: false` на blue-green проекте | слот выложен **до** появления этой функции, `.bluegreen.json` нет | выполнить один деплой: файл запишется в оба слота |
| `live: null`, в `error` «malformed» | `.bluegreen.json` повреждён | передеплоить слот |
| `live: null`, `error` «deployer unreachable» | деплоер недоступен, а прошлого значения ещё нет | проверить деплоер; если он не слушает loopback — задать `status_url` в его `setup.json` |
| `stale: true` | деплоер временно недоступен | ничего: работаем на последнем известном значении |
| `error` «deployer rejected… Invalid project or status token» | токен в файле не совпадает с state-файлом | передеплоить слот |
| После activate приложение ещё несколько секунд видит старый слот | интервал опроса | нормально; для мгновенной проверки — `getBlueGreenActiveState({ force: true })` |
| Синглтон-работа выполняется дважды после перехода на blue-green | работает старый процесс без слота | `pm2 delete <name>` (раздел 8) |

Проверка на сервере вручную:

```bash
cat <projects_path>/<name>_state_.json                # active / lastDeployed / history
cat <nginx.config_dir>/<name>.upstream.conf           # куда реально смотрит nginx
cat <destination>/<slot>/.bluegreen.json              # паспорт слота
pm2 list | grep <name>                                # оба слота запущены?
```
