textmachine/platform/deploy/README.md

953 lines
96 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Развёртывание платформы
Одна VM, systemd, бинари артефактами CI (`PLATFORM_DIRECTION.md` §3). Не Kubernetes: дети-`tmctl`
живут часами и держат эксклюзивный лок на файлах книги на локальном диске — оркестратор, способный
переселить под посреди прогона, этой нагрузке враждебен.
## Файлы
- `tmplatformd.service` — юнит контрольной панели. Тело юнита проверено `systemd-analyze verify`
(systemd 259) — exit 0, без замечаний. ⚠ Проверять надо с ПОДСТАВЛЕННЫМ существующим `ExecStart=`:
дословно юнит даёт exit 1, потому что `verify` проверяет и наличие бинаря, а `/usr/local/bin/tmplatformd`
на стенде нет. ⚠ **юнит целиком под systemd не запускался** — нет sudo и нет бинаря на стенде.
Отдельные свойства песочницы при этом проверены ЖИВЫМ прогоном в пользовательском systemd 259
(`systemd-run --user --wait`), а не вычитаны из доки:
`ProtectSystem=strict` + `ReadWritePaths=` на несуществующем пути → **`226/NAMESPACE`, юнит не
стартует**; тот же путь созданным → `0/SUCCESS`; префикс `-` на несуществующем → `0/SUCCESS`
(строка игнорируется — потому мы её и не префиксуем);
`ProtectHome=yes``/home` пуст, чтение `/home/<user>` даёт `Permission denied`;
`ProtectHome=tmpfs` + `BindPaths=<каталог>` → каталог виден. Ресурсные потолки
(`MemoryMax=80%`, `OOMPolicy=continue`) живьём не проверялись — вывод из
`systemd.resource-control(5)`/`systemd.service(5)`.
## Откат релиза: не ниже версии 15
**Граница была названа «5» и это уже неверно.** `goose down` до версии 14 и ниже НЕ РАБОТАЕТ на
живой базе: down-путь `00015` сужает `runs_paused_reason_check` обратно к одному `credit_exhausted`,
а боевой код пишет ещё два значения — `daily_ceiling` и `ceiling_unknown` (оба через
`internal/pgstore/books.go` `CeilingPause`). Значит `DownTo(<15)` на базе, где такая пауза
случалась, падает РАНЬШЕ, чем дойдёт до `00005`. Носитель — открытый ряд `PD-218`.
Ниже 5 не работает и подавно: down-путь `00005` восстанавливает
`users_email_key` и `email NOT NULL`, а обе формы нарушают строки, которые пишет боевой код
(неподтверждённая личность даёт `email = NULL`; один адрес законно принадлежит двум аккаунтам).
Откат транзакционный, поэтому падение ничего не портит — но планировать откат ниже 15 нельзя,
план отката — накатить вперёд. Разбор: `docs/STACK_DECISIONS.md` §8.
## Что юнит закрывает содержательно
-**ПЕРЕ-ДИСПОЗИЦИЯ (P4, D39.106): прогон НЕ ребёнок этого юнита и не живёт в его cgroup.**
Каждый прогон — транзиентный юнит в СОБСТВЕННОМ systemd-менеджере пользователя `tmplatform`,
в срезе `tm-runs.slice`, потому что перевод обязан пережить деплой платформы. Следствия для
установки: обязателен `loginctl enable-linger tmplatform`; юниту нужен доступ к своей же
пользовательской шине (`ProtectHome=tmpfs` + `BindPaths=/run/user/%U` +
`DBUS_SESSION_BUS_ADDRESS`); ресурсные потолки НИЖЕ ограничивают только контрол-плейн.
- **PD-13 (осиротевшие процессы движка)** закрыт теперь ТАМ, где прогон: собственным cgroup прогона
(`TM_PLATFORM_RUN_MEMORY_MAX`/`_TASKS_MAX`), и это ИЗМЕРЕНО — в дефолтном `app.slice`
пользовательского менеджера лимиты принимаются и не применяются (`STACK_DECISIONS` §16).
Дев-путь супервизора с группой процессов остаётся дев-путём.
- **`TimeoutStopSec=90`** ограничивает остановку САМОГО ДЕМОНА: дренаж HTTP плюс останов очереди,
с запасом. ⚠ Прежняя редакция объясняла его «grace движка 30 с» — такой грации нет: `stopGrace`
равен 10 МИНУТАМ (`internal/runner/runner.go:59`=`const stopGrace = 10 * time.Minute`) и штампуется как `TimeoutStopSec` на
ТРАНЗИЕНТНОМ юните прогона, а не на этом. ⚠ Но и обратное неверно: четыре движковых вызова —
`manifest`/`status`, сборка экспорта, `bank-apply` и сам `systemd-run` — идут ПРЯМЫМИ ДЕТЬМИ
демона, лежат в его cgroup, и этот таймаут на них распространяется. Разбор — комментарий в
`deploy/tmplatformd.service` над строкой `TimeoutStopSec=`.
- **Секреты через `LoadCredential=`,** а не через окружение: переменная окружения видна в
`/proc/<pid>/environ` и наследуется каждым ребёнком-`tmctl`. Конфиг читает `*_FILE` первым.
## Установка (набросок, исполняется владельцем)
```sh
useradd --system --home-dir /srv/textmachine tmplatform
# ⚠ Каталог создаём ЯВНО: --system не создаёт домашний каталог, а `ReadWritePaths=/srv/textmachine`
# при `ProtectSystem=strict` на несуществующем пути валит сборку mount-namespace — юнит не стартует
# вовсе (PD-91, systemd.exec(5)). Это первая команда, которую пропускают, читая набросок сверху вниз.
install -d -m0750 -o tmplatform -g tmplatform /srv/textmachine
install -D -m0755 tmplatformd /usr/local/bin/tmplatformd
install -D -m0755 tmplatformctl /usr/local/bin/tmplatformctl
# ⚠ КЛИЕНТСКИЕ ТУЛЫ POSTGRES — их ставит НЕ пакет базы, и на хосте контрол-плейна их обычно нет
# вовсе (база живёт отдельно). Без них точки восстановления НЕ СНИМАЮТСЯ. Замер: рецепт, исполненный
# дословно в чистом контейнере, дал здоровый на вид инстанс, чей первый бэкап-пасс упал
# `exec: "pg_dump": executable file not found in $PATH`.
# ⚠ Отказ виден и БЕЗ отдельной строки на буте — первый проход идёт при старте, — но говорит он
# только СИМПТОМ; строка демона называет ТУЛ и правило мажоров, то есть лекарство.
# ⚠ МАЖОР обязан совпадать с мажором СЕРВЕРА: pg_dump отказывается снимать дамп с сервера новее себя.
apt-get install -y postgresql-client-18 # или postgresql18 из репозитория PGDG — по мажору сервера
# ⚠ ДЛЯ БОЕВОГО ДЕПЛОЯ ОБЯЗАТЕЛЬНО. Прогоны — транзиентные юниты в пользовательском менеджере
# `tmplatform`, а он ПЕРЕЖИВАЕТ выход из сессии только при включённом linger.
# ⚠ Формулировка «без этого не стартует НИ ОДИН прогон» снята 04.09 как ложная для стенда: в
# ЖИВОЙ сессии менеджер уже есть, и замер зоны (H2) дал `Linger=no` при шести стартовавших
# прогонах. Сессия, честно исполнившая прежнюю проверку, решала, что стенд сломан.
# Проверка: `systemctl --user -M tmplatform@ is-system-running` отвечает, а не «Failed to connect».
loginctl enable-linger tmplatform
# Движок кладётся по ВЕРСИОНИРОВАННОМУ пути: попытка прогона пиннится к тому, с которого началась
# (строка 139), поэтому выкат новой версии не подменяет исполняемое у идущего прогона.
install -D -m0755 tmctl /opt/textmachine/engine/<версия>/tmctl
install -d -m0700 -o root -g root /etc/tmplatform
printf '%s' 'postgres://...' > /etc/tmplatform/dsn && chmod 0400 /etc/tmplatform/dsn
printf '%s' '<oauth client secret>' > /etc/tmplatform/oidc_client_secret && chmod 0400 /etc/tmplatform/oidc_client_secret
```
`/etc/tmplatform/env` — несекретное окружение. ⚠ **Это файл для `EnvironmentFile=` systemd, а НЕ
шелл-скрипт: `source /etc/tmplatform/env` его ЛОМАЕТ.** systemd разбирает `KEY=VALUE` сам и шелла не
зовёт, поэтому `zh>ru` для него — значение; шелл прочтёт `>` как ПЕРЕНАПРАВЛЕНИЕ, создаст рядом файл
`ru` и оставит `TM_PLATFORM_LANGUAGE_PAIRS=zh`, после чего демон откажется стартовать, жалуясь на
пары. Поймано исполнением рецепта в контейнере 05.09. Проверять окружение — `systemctl show
tmplatformd -p Environment`, а не `source`:
```
TM_PLATFORM_ADDR=127.0.0.1:8080
TM_PLATFORM_TRUSTED_ORIGINS=https://app.example.org
TM_PLATFORM_OIDC_ISSUER=https://accounts.google.com
TM_PLATFORM_OIDC_CLIENT_ID=...
TM_PLATFORM_OIDC_REDIRECT_URL=https://app.example.org/auth/callback
TM_PLATFORM_AFTER_LOGIN=/library
TM_PLATFORM_LANGUAGE_PAIRS=zh>ru
TM_PLATFORM_BOOKS_DIR=/srv/textmachine/books
TM_PLATFORM_BOOK_TEMPLATE=/srv/textmachine/book-template.yaml
TM_PLATFORM_METRICS_ADDR=127.0.0.1:9464
# ⚠⚠ ДОПИСАНО 29.08, ПЕРЕ-СНЯТО 04.09. Прежняя редакция говорила «без этих четырёх инстанс не
# запустит ни одного перевода» — неверно для ДВУХ из четырёх, разбор ниже под блоком. Держи их
# все, но знай, ЧТО именно ломает каждая: считать обязательными все четыре дешевле не выходит —
# оператор, увидев, что без CTL_BIN всё работает, перестаёт верить и остальным трём.
TM_PLATFORM_ENGINE_BIN=/opt/textmachine/engine/<версия>/tmctl # пусто = инстанс только читает
TM_PLATFORM_CTL_BIN=/usr/local/bin/tmplatformctl # его зовёт юнит как ExecStopPost
TM_PLATFORM_STATE_DIR=/var/lib/tmplatform/state # маркеры выхода; ТОЛЬКО абсолютный
TM_PLATFORM_ENGINE_KEYS_PATH=/etc/tmplatform/engine-keys # KEY=VALUE, едет --keys-file
# Дверь выдачи книги файлом (04.09). НЕОБЯЗАТЕЛЬНА: пусто = дверь не смонтирована и
# `export_formats` пуст — клиент видит это на /capabilities и кнопки не рисует.
TM_PLATFORM_EXPORT_FORMATS=epub,txt # что УМЕЕТ ЭТОТ движок; порядок = порядок в /capabilities
TM_PLATFORM_EXPORTS_DIR=/var/lib/tmplatform/exports # ТОЛЬКО абсолютный; пусто = <STATE_DIR>/exports
TM_PLATFORM_EXPORT_TTL=24h # сколько живут артефакт и ссылка
# ⛔ ТОЧКИ ВОССТАНОВЛЕНИЯ (05.09). Пусто = деплой НЕ ХРАНИТ НИ ОДНОЙ КОПИИ оплаченной работы и
# денежного реестра; демон говорит это WARN'ом на каждом старте. Разбор — раздел «Бэкап и
# восстановление» ниже, там же требование к УСТРОЙСТВУ (другой диск) и рецепт восстановления.
TM_PLATFORM_BACKUP_DIR=/var/backups/tmplatform # ТОЛЬКО абсолютный; пусто = точек нет
TM_PLATFORM_BACKUP_EVERY=6h # как часто снимается точка
TM_PLATFORM_BACKUP_KEEP=14 # сколько поколений живёт: 14 × 6h = 84 ч, значит самая
# старая ЖИВАЯ точка — 13 интервалов, ≈ 3,25 суток
TM_PLATFORM_PGDUMP_BIN=pg_dump # МАЖОР обязан совпадать с сервером
TM_PLATFORM_PGRESTORE_BIN=pg_restore
```
**Что из этой четвёрки ДЕЙСТВИТЕЛЬНО гейтит прогон — одна переменная, а не четыре.** Остальные
три ломаются иначе, и знать чем дешевле, чем считать их все обязательными:
- **`ENGINE_BIN`** — ЕДИНСТВЕННАЯ, чьё отсутствие останавливает прогоны прямо: пусто ⇒ инстанс
объявляет себя читающей репликой, не спавнит ничего и не принимает загрузок
(`cmd/tmplatformd/runner.go:71`=`the library is served read-only`);
- **`CTL_BIN`** — путь, который юнит зовёт в `ExecStopPost`, чтобы записать маркер выхода.
**ПУСТО — НЕ отказ:** берётся `tmplatformctl` РЯДОМ с демоном (`internal/config/config.go:528`=`MarkerBinary: l.env("TM_PLATFORM_CTL_BIN", "")`,
сиблинг-дефолт `cmd/tmplatformd/runner.go:601`=`bin = filepath.Join(filepath.Dir(self), "tmplatformctl")`), а рантбук ставит его туда же, куда и демона
(`install -D -m0755 tmplatformctl /usr/local/bin/tmplatformctl` в блоке установки выше).
Отказывает **НЕВЕРНЫЙ** путь: `os.Stat` не находит его на буте (`runner.go:87`
WARN, не паника), `MarkerArgv` остаётся пустым, и каждый старт/резюм отвечает `503`
(`internal/runs/runs.go:415`=`func (s *Service) runnable() error {` `ErrRunnerIncomplete``internal/httpapi/v0.go:997`=`errors.Is(err, runs.ErrRunnerIncomplete)`).
⚠ Прежняя редакция объясняла его ОТСУТСТВИЕ симптомом «прогон завершается, а платформа не узнаёт»
— такого исхода в коде нет: ровно чтобы он не наступил, зона и отказывает на старте
(`internal/runs/runs.go:151`=`ErrRunnerIncomplete is a DEPLOYMENT that cannot start runs`);
- **`STATE_DIR`** — каталог маркеров. ⚠ **Прежняя редакция объясняла его ложно** («дефолт вне
`ReadWritePaths=`, запись запрещена файловой системой»): дефолт — `/var/lib/tmplatform`
(`internal/config/config.go:527`=`StateDir: l.env("TM_PLATFORM_STATE_DIR", "/var/lib/tmplatform")`), а это ровно то, что юниту даёт `StateDirectory=tmplatform`
(строка `StateDirectory=tmplatform` в `deploy/tmplatformd.service`), — systemd создаёт каталог
и делает его записываемым ПОД
`ProtectSystem=strict`, а сам маркер пишет `ExecStopPost` ТРАНЗИЕНТНОГО юнита прогона, на
котором песочницы нет вовсе. ⚠ Задавать явно всё же стоит, но по ТРЁМ ДРУГИМ причинам, и они
настоящие: путь обязан быть абсолютным (демон отказывает на старте, `internal/config/config.go:549`=`TM_PLATFORM_STATE_DIR must be an absolute path`) ·
каталог обязан принадлежать пользователю ПРОГОНОВ — по его владельцу админ-CLI отличает свой
systemd от чужого и без этого отказывается судить о судьбе прогона
(`cmd/tmplatformctl/runs.go:360`=`notTheRunsOwnManager` `notTheRunsOwnManager`) · и он не должен меняться при живых
прогонах: смена осиротляет exit-маркеры идущих (`PD-155`);
- **`EXPORT_FORMATS`** — не из четвёрки и не обязательна, но у неё своя ловушка: это
ДЕКЛАРАЦИЯ ОПЕРАТОРА о том, что умеет РАЗВЁРНУТЫЙ здесь движок, а не открытие. Список
форматов живёт Go-переменной внутри движка, импортировать который платформе запрещено
(`D39.85`), и $0-команды, публикующей его данными, у движка нет. Объявите формат,
которого этот движок не знает, — и заказ на него вернётся ОТКАЗАВШИМСЯ экспортом с
кодом `deployment_error`: честно, но чинить это вам. ⚠ `EXPORTS_DIR` абсолютен по
причине острее, чем у `STATE_DIR`: путь уходит движку аргументом `--out`, а движок
работает с каталогом КНИГИ как рабочим, поэтому относительный писал бы артефакты
внутрь чужого проекта. ⚠ **И он обязан быть ЗАПИСЫВАЕМ ВНУТРИ ПЕСОЧНИЦЫ ЮНИТА** — для
`BOOKS_DIR` эта оговорка написана, для экспорта её не было. Дефолт `<STATE_DIR>/exports`
лежит под `StateDirectory=tmplatform` и потому пишется; любой путь вне его надо добавить
в `ReadWritePaths=`. ⚠ Симптом НЕ тот, которого ждёшь: каталог создаёт САМ ДЕМОН НА БУТЕ
(`cmd/tmplatformd/runner.go:280`=`exports directory %s: %w`), и невозможность создать — **ОТКАЗ СТАРТА** демона
(`exports directory <путь>: …`), а не отказ отдельного экспорта. Каталог, который есть,
но доступен только на чтение, роняет создание подкаталога КНИГИ на первом же экспорте
(`internal/exports/exports.go:358`=`os.MkdirAll(filepath.Dir(out), 0o750)`), и код отказа там `build_failed`, а не
`deployment_error`;
- **`ENGINE_KEYS_PATH`** (четвёртая из «четвёрки»; **старту НЕ мешает** — демон пишет один WARN
и поднимается здоровым, `cmd/tmplatformd/runner.go:99`=`no TM_PLATFORM_ENGINE_KEYS_PATH`, принимает книгу и БЕРЁТ ХОЛД, а падает
первый платный вызов, то есть уже после резервирования денег пользователя — замер H1 зоны) —
ЕДИНСТВЕННЫЙ канал провайдерских ключей в движок (едет аргументом
`--keys-file`, мимо процесса платформы и мимо окружения юнита). Без него движок ищет `.env` рядом
с `book.yaml`, которого SaaS-путь не пишет, и каждый платный прогон падает `exit 10`
(«missing API keys»). На буте это WARN, а не отказ, — то есть тихо.
**`TM_PLATFORM_SIGNUP_GRANT_USD` здесь НЕТ намеренно** (PD-104, слово владельца 16.08): на бете
грант по умолчанию НОЛЬ и начисляется руками — `tmplatformctl grant`. Строка `=5` в этом блоке
включала обратно ровно тот самообслуживаемый безлимитный грант, который дефолт выключает.
**`TM_PLATFORM_LANGUAGE_PAIRS` обязателен, и правило ЖЁСТКОЕ** (акт 5 P7). Инстанс,
который ПРИНИМАЕТ ЗАГРУЗКИ и не объявил ни одной ДОСТУПНОЙ пары, **не стартует вовсе**
(`internal/config/config.go`, проверка `IntakeEnabled() && len(AvailablePairs()) ==
0`); объявленная, но недоступная пара отклоняется интейком кодом `unsupported_pair`. То
есть «не объявлено ничего» и «объявлена эта пара» — разные ответы, и деплой больше не
может принять книгу, которую он способен только провалить.
## Шаблон книги (`TM_PLATFORM_BOOK_TEMPLATE`) — артефакт ОПЕРАТОРА
Загруженной книге нужен `book.yaml`, и пишет его платформа — ОДИН раз, при первом
разборе, из этого шаблона (форма Б, D39.130). Дальше файл принадлежит оператору:
платформа его не читает и никогда не перезаписывает (`O_EXCL`), поэтому правка потолка
или пайплайна в конкретной книге переживает всё.
Платформа подставляет ровно то, что знает только она: `book_id` · `title` ·
`source_lang` · `target_lang` · `source_file`. Всё остальное едет из шаблона нетронутым
— включая ключи, о которых эта версия платформы не слышала, и включая `genre`: он ушёл
из контракта и из интейка в 0.3.0 (Б-23), поэтому теперь это значение ОПЕРАТОРА и
платформа его не трогает.
```yaml
# /srv/textmachine/book-template.yaml
pipeline: /opt/textmachine/configs/pipeline-c1.yaml
models: /opt/textmachine/configs/models.yaml
langpack_root: /opt/textmachine/configs/langpacks
audience: взрослые читатели вебновелл
venuti: 0.6
honorifics: keep
transcription: palladius
footnotes: minimal
ceilings:
book_usd: 25
```
**Пути внутри шаблона — АБСОЛЮТНЫЕ.** Движок резолвит относительный путь от каталога
КНИГИ, а не от каталога шаблона, поэтому `../configs/pipeline.yaml` в шаблоне указывает
на разные файлы для каждой книги и ни на один из них — правильно.
**`ceilings.day_usd` в шаблоне НЕ НУЖЕН — и в примере его нет намеренно** (решение
владельца 15.08). Трату прогона уже жёстко ограничивает купленный пользователем объём:
платформа держит холд и передаёт движку `--ceiling-usd`, флаш к этому холду. Дневная
ось поверх этого — второй лимит на ту же трату, и стоит он дороже, чем даёт:
остановленный им прогон приезжает `paused` (не `failed`), но продолжить его платформа
не может — `resume` отвечает 409, потому что следующая попытка упёрлась бы в тот же
лимит в тот же день, а пользователю эта пауза необъяснима: лимит не его и он его не
видит.
Движка это не касается — `ceilings.day_usd` у него остаётся, и вписать его в конкретную
книгу руками оператор вправе. Именно на этот случай платформенная обработка
`daily_ceiling`/409 из P6 и остаётся предохранителем: она делает такую остановку
различимой и честной, а не дефолтом, который её вызывает.
Битый или отсутствующий шаблон — **беда деплоя, а не книги**: загрузки не отклоняются и
не удаляются, книги ждут в `parsing` (метрика
`tm_platform_books_in_intake{status="parsing"}`), в логе — ERROR с причиной. Починили
файл — ближайший свип разбирает всё накопившееся.
## ⛔ Артефакт контраста: положить ДО первой выкатки, иначе прогоны отказаны
Боевой конфиг пайплайна включает банковый контур, а у контура ровно ОДИН вход, которого **нет в
git и не должно быть**: словарь частотности слов ИСХОДНОГО языка, `mining.contrast_path`
(`backend/configs/pipeline-c1.yaml`, греп `contrast_path` — там же движковая половина разбора,
здесь она не дублируется).
**Что будет без него.** Загрузка конфига проходит: ключ задан, а ФАЙЛ проверяется отдельно и только
на ПИШУЩЕМ пути (`backend/internal/config/pipeline.go`, греп `CheckMiningContrast`; зовётся из
`backend/internal/pipeline/runner.go` под `if forWrite`). Значит инстанс поднимается здоровым,
принимает загрузки, режет книги, строит выдачи — и **отказывает КАЖДОМУ `translate` и КАЖДОМУ
резюму** классом конфигурации (`exit 10`), до того как потрачен хоть один цент. По полосе кодов это
«деплой сломан, чинит человек, книги ждут» (`docs/architecture/17-seam-inbound-law.md` п.2), и
пользователь видит прогон, который не начинается.
⚠ Симптом НЕ «контур не работает тихо»: тихой эта поломка была бы БЕЗ проверки — тогда движок купил
бы всю черновую волну и упал на стопе майнинга. Проверка сделана ровно чтобы этого не было, и её
цена — отказ на старте, который надо уметь прочитать.
**Что положить.** Файл кладётся РЯДОМ С КОНФИГОМ ПАЙПЛАЙНА: путь в конфиге относительный и
резолвится от каталога того файла, а не от каталога книги и не от рабочего каталога процесса
(`backend/internal/config/pipeline.go`, греп `resolvePrompt`). То есть при
`pipeline: /opt/textmachine/configs/pipeline-c1.yaml` и `contrast_path: mining-contrast.zh.txt`
файл обязан лежать по `/opt/textmachine/configs/mining-contrast.zh.txt`.
**Откуда взять и чем проверить.** Артефакт генерируется из jieba 0.42.1; процедура и SHA пина —
`docs/experiments/16-bank-mining.md`. Проверка выкатки — не `ls`, а $0-глагол движка на любой
заведённой книге:
```sh
# Кладём рядом с конфигом пайплайна, которым живёт шаблон книги.
install -m0644 mining-contrast.zh.txt /opt/textmachine/configs/mining-contrast.zh.txt
# Проверка ДО первого платного прогона: --dry-run ничего не покупает и ничего не меняет, но идёт по
# ПИШУЩЕМУ пути, то есть через ту же проверку.
/opt/textmachine/engine/<версия>/tmctl redrive --config /srv/textmachine/books/<book_id>/book.yaml --dry-run
```
**ЭТА ПРОВЕРКА ОТКРЫВАЕТ ПРОЕКТ НА ЗАПИСЬ, и на книге с идущим прогоном её запускать нельзя.**
`redrive` даже с `--dry-run` идёт пишущим путём (`pipeline.NewRunner`), а значит берёт эксклюзивный
flock проекта и мигрирует файл при необходимости. На занятой книге это отказ класса 12 («ничего не
произошло, подожди») — не порча, но и не проверка. Гоняйте на книге, по которой прогонов нет:
`tmplatformctl books` покажет, у кого их нет.
**СУДИТЬ ПО СООБЩЕНИЮ, А НЕ ПО КОДУ ВЫХОДА — оба исхода дают `exit 10`.** Замерено 05.09 обеими
ветками:
```
артефакта НЕТ: exit=10 tmctl: the bank contour is enabled and `mining.contrast_path` names …
which is not readable: … no such file or directory
артефакт ЕСТЬ: exit=10 tmctl: missing API keys (fill in backend/.env): …
```
Вторая строка — это УСПЕХ проверки контраста: `CheckMiningContrast` стоит в пишущей ветке ПЕРЕД
проверкой ключей (`backend/internal/pipeline/runner.go`, греп `CheckMiningContrast`), поэтому дойти
до жалобы на ключи можно только миновав контраст. Ключей у `redrive` в SaaS-развёртывании и не будет:
они едут `--keys-file`, а его движок принимает только у `translate`.
**ДВЕ РАЗНЫЕ ОШИБКИ РАЗВЁРТЫВАНИЯ, И ГРОМКАЯ — НЕ ТА, О КОТОРОЙ ДУМАЕШЬ.** ⛔ Прежняя редакция
этого абзаца утверждала обратное и была НЕВЕРНА: она называла тихой потерю КЛЮЧА и громкой потерю
файла. Замер обеих веток (05.09, `tmctl manifest --json` на одной книге):
| что потеряно | код выхода | что видит оператор |
|---|---|---|
| **строчка `contrast_path` в конфиге** | **`10`** | конфиг НЕ ЗАГРУЖАЕТСЯ вовсе, отказ на ЛЮБОМ пути, включая `$0`-читающие |
| **сам файл артефакта** | **`0`** | всё работает, манифест отдаётся; отказывает только ПИШУЩИЙ путь — каждый `translate` и резюм |
Причина в коде и она намеренная: пустой ключ судится ПРИ ЗАГРУЗКЕ
(`backend/internal/config/pipeline.go`, греп `mining.contrast_path` is not set), а наличие ФАЙЛА —
только на пишущем пути (`CheckMiningContrast`), потому что `LoadPipeline` лежит и на `$0`-поверхностях,
и отказывать `status` из-за отсутствующего файла данных — ошибка класса D20.4.
⇒ **Забытая строчка в конфиге ловится немедленно и всем; забытый файл — только первым платным
прогоном.** Опасен второй, и именно поэтому раздел выше велит класть артефакт ПЕРЕД выкаткой.
**Порядок в первой выкатке: артефакт → шаблон книги → демон.** Книга, заведённая раньше файла, не
ломается и ничего не теряет — отказ приходит на старте прогона, а не на интейке, — но пользователь
успевает нажать «перевести» и получить отказ, за который платить некому.
## Апгрейд ПЛАТФОРМЫ: миграция и бинарь едут вместе
**Схема и бинарь платформы — один шаг, а не два.** Любая миграция, снимающая колонку,
делает перекрытие версий невозможным: инстанс старой сборки рядом с уже мигрированной
базой отвечает 500 на КАЖДОЕ чтение книги. Так было у 00016 (`books.note_count`,
`books.genre`, `chapters.heading`, таблица `notes`) и у 00021 (`chapters.units_done`,
акт 5 P7) — проверять надо на КАЖДОЙ. Порядок: остановить старую версию → накатить
миграцию (`TM_PLATFORM_MIGRATE=1` на одном инстансе или `goose` руками) → поднять
новую. Роллинг-апгрейд с перекрытием версий на этом шаге не поддерживается; дефолт
`TM_PLATFORM_MIGRATE` выключен именно поэтому — накат должен быть решением, а не
побочным эффектом старта реплики.
**Отредактированная миграция не перезапускается.** goose ключуется НОМЕРОМ и не
хранит ни имени, ни контрольной суммы, так что правка уже применённого файла невидима:
на стенде это лечится сносом базы, в проде — гейтом
`internal/pgstore/migrations.sha256`, который делает правку выпущенной миграции видимой
ревьюеру. Поймано живой пробой P7 (правка 00016 после её применения на стенде).
## Апгрейд ДВИЖКА: порядок против деадлока (строка 174 единого бэклога)
Read-only команды движка отказывают файлу проекта СТАРЕЕ бинаря —
**exit 13 и токен `schema_mismatch found=N expected=M` на stderr**
(форма финализирована D39.134 п.4), а платформа зовёт `status` перед
каждым спавном и на расчёте денег. Значит выкат нового движка на
хост с существующими книгами запирает их до миграции файла проекта.
**Проверено исполнением на стенде (P7):** проектная БД, отведённая на схему v14 при
бинаре v15, даёт `tmctl status --json` → exit 13 с этим токеном; `tmctl migrate
--config <book.yaml>` делает пред-миграционный бэкап и переводит v14 → v15; тот же
`status` после неё — exit 0.
**`tmctl migrate` СУЩЕСТВУЕТ и заленден** — $0-команда
движка, открывающая файл проекта на запись без прогона:
`backend/cmd/tmctl/migrate.go`, в диспетчере `main.go`, свой
код выхода (`exitSchemaMismatch = 13`, «run `tmctl migrate`»).
⚠⚠ **ВТОРОЙ, НЕЗАВИСИМЫЙ шов — ФОРМА МАНИФЕСТА движка, и у её бампа
безопасного порядка НЕТ ни в одну сторону.** Платформа с P12 сверяет
`manifest_version` с известной ей формой (`ingest.KnownManifestVersion`,
зеркало `manifestVersion` движка) и на незнакомую отвечает НЕ-деструктивным
классом `parser_unavailable` — файл пользователя цел, и это осознанный выбор:
без сверки переименованный ключ декодируется в нули, а ноль глав интейк читает
как «источник прочли, книги нет», то есть УДАЛЯЕТ аплоад (`PD-213`). Но класс
всё равно ТЕРМИНАЛЕН по бюджету попыток: каждая загруженная книга уходит в
`rejected` после пяти попыток. Гейт — СТРОГОЕ РАВЕНСТВО одной константе
(`internal/ingest/manifest.go`, греп `m.Version != KnownManifestVersion`), окна
двух форм в нём нет, поэтому односторонняя выкатка смертельна с ОБЕИХ сторон:
движок впереди платформы ⇒ платформа не знает новую форму; платформа впереди
движка ⇒ платформа не знает СТАРУЮ, и в `parser_unavailable` уезжает каждая
книга ещё не обновлённого движка. Симптом один и тот же — интейк массово
отклоняет при здоровом на вид движке. ⚠ Прежняя редакция этого абзаца
советовала «сначала платформа, потом движок» и ссылалась на правило схемы
хранилища — это было неверно дважды (`PD-436`): у формы манифеста порядка нет,
а правило схемы (раздел ниже) — «движок первым → `migrate` → платформа», про
другой шов. **Бамп формы манифеста — стоп-мир:** закрыть приём
(`systemctl stop tmplatformd`; остановленный демон не принимает загрузок),
выкатить ОБА билда, открыть приём. Безопасный порядок появится только вместе с
окном двух форм в гейте (константа → набор известных форм) — сегодня его нет и
это не заказано.
**Гейт версии стоит ТОЛЬКО на интейке** (`ingest.Manifest.Readable`, единственный
вызывающий — `internal/books/parse.go`). Материализация читающей поверхности на конце
прогона версию НЕ сверяет — она требует лишь самосогласованности документа
(`manifest.Whole()` в `internal/readmodel`) и спрашивает манифест у ДЕПЛОЙНОГО бинаря
(`TM_PLATFORM_ENGINE_BIN`), а не у того, к которому запинен прогон. Значит живой прогон
стоп-мир переживает, а его книга в худшем случае теряет СВЕЖЕСТЬ: незнакомая форма
декодируется в нули, `Whole()` отказывается переписывать дерево, долг откладывается и
после пяти попыток списывается — книга остаётся с прежним текстом (`books --abandoned`,
`book refresh`). Дренаж «трёх фактов» (шаг 1 ниже) обязателен не поэтому, а из-за схемы
хранилища ДВИЖКА — это соседний раздел и другой отказ (exit 13).
Порядок (действующий). ⚠ Ключевое: **`migrate` гоняется НОВЫМ бинарём** — старый уводит
файл в свою же схему, то есть не делает ничего, и деадлок остаётся. Поэтому бинарь
кладётся ДО миграции, а `TM_PLATFORM_ENGINE_BIN` переключается ПОСЛЕ.
```sh
# 0. Закрыть приём. Между списком и миграцией окно, в которое пользователь может стартовать прогон
# на книге, только что названной безопасной; остановленный демон не принимает ни стартов, ни
# резюмов. Идущие прогоны переживают это по построению — они в своём менеджере (P4, D39.106).
systemctl stop tmplatformd
# 1. Дренаж. «Прогонов нет» — это ТРИ факта, а не один: нет живых юнитов, нет открытых
# (резюмируемых) попыток И нет незакрытых холдов.
tmplatformctl books # колонка MIGRATE и причина, если «no»
# 2. Новый бинарь — на ВЕРСИОНИРОВАННЫЙ путь. Безопасно ещё до переключения: идущие попытки
# запинены к своим путям, а этот никто пока не зовёт.
install -D -m0755 tmctl /opt/textmachine/engine/<новая версия>/tmctl
# 3. Мигрируем ЕГО бинарём и только те книги, которые команда назвала безопасными.
for d in $(tmplatformctl books --migratable); do
/opt/textmachine/engine/<новая версия>/tmctl migrate --config "$d/book.yaml"
done
# 4. И только теперь новая версия становится рабочей: TM_PLATFORM_ENGINE_BIN в /etc/tmplatform/env,
# затем поднять демон.
systemctl start tmplatformd
```
Почему шаг 1 — про три факта, а не про один. Остановленная попытка ждёт резюма на
бинаре, к которому она ЗАПИНЕНА (строка 139): миграция уводит файл вперёд, старый
бинарь его больше не откроет, и резюм пришлось бы форсить на новую сборку — риск
пере-оплаты уже купленных вызовов. По той же причине блокирует НЕЗАКРЫТЫЙ холд: расчёт
денег читает `committed_usd` тем же запиненным бинарём, и после миграции цифру уже не
прочитать. Все три предиката запинены тестом
(`TestEachOfTheThreeBlockersAloneKeepsABookOutOfTheMigrationList`) — каждый по
отдельности, потому что «блокер, который тихо перестал блокировать» виден только на уже
прошедшем апгрейде.
Книга, застрявшая на `daily_ceiling` или на вечно незакрытом холде, блокирует апгрейд
бессрочно, и выхода для оператора сегодня нет (строка регистра PD-217): такую книгу
придётся либо дождаться, либо разрешить вручную в БД. Форсирующего флага у команды нет
намеренно — он бы и был тем самым способом пере-оплатить купленное.
**Хвост, который НЕ построен:** самолечение платформы — поймала `schema_mismatch`
зовёт `migrate`, если у книги нет открытых попыток → повтор, без стоп-мира. Строка
регистра PD-201.
## Застрявшая работа: что оператор делает, когда свип не справляется
Диагноз и терминальный вердикт для двух состояний, которые свип не закрывает сам
(P8-FIX).
**Прогон, который реконсилятор не может закончить.** Признак: растёт
`tm_platform_sweep_unfinished_total`, гейдж `tm_platform_runs_stalled` больше нуля.
```sh
tmplatformctl runs # живые прогоны И застрявшие расчёты
tmplatformctl runs --stalled # только те, что свип не доводит: сколько неудач подряд,
# когда следующая попытка, что сказал движок, сколько держит
```
**Колонка `PHASE` говорит, какая это половина, и лечение у них разное.** `live`
прогон ещё идёт, и первый вопрос к systemd. `settling` — прогон УЖЕ кончился, движка
нет, а его расчёт не сходится: деньги пользователя заморожены в открытой резервации, и
вопрос к systemd бессмыслен.
Дальше решает человек, а не платформа: сперва спросить systemd (`systemctl --user
status <юнит>`). Если процесс жив — остановить его и дать реконсилятору закрыть прогон
штатно, это лучше во всём. Если движок не ответит уже никогда (каталог книги унесён,
маунт отвалился):
```sh
tmplatformctl run abandon --run <id> --reason "почему" [--release-hold]
```
`--reason` обязателен: это единственная запись о том, почему оплаченный прогон объявлен
законченным.
⚠⚠ **С 04.09 КОМАНДА СПРАШИВАЕТ SYSTEMD САМА, и это меняет то, что оператор делает
руками** (`PD-424`, живая половина). Раньше она отказывала любой попытке, которая ещё
называет юнит или несёт базовую линию траты, и правильно отказывала: платформа не может
отличить НАЗВАННЫЙ юнит от РАБОТАЮЩЕГО. Чего она не делала — не спрашивала. Теперь
делает, и ответов **ЧЕТЫРЕ**:
- **юнита нет** — прогон закрывается, деньги закрываются в этой же транзакции;
- **юнит активен** — ОТКАЗ, с тем же советом, что и раньше: остановите его и дайте
реконсилятору закрыть прогон штатно;
- **systemd недоступен** — тоже ОТКАЗ. Отсутствие ответа не есть отсутствие процесса, и
цена ошибки здесь — живой движок, который продолжает тратить против книжного потолка,
когда холд аккаунта уже закрыт: этот расход не оплатит никто, и провайдеру платит
ДЕПЛОЙ. Ограничен он потолком того же прогона, но он есть;
- **это НЕ ТОТ systemd** — ОТКАЗ, и он самый неочевидный из четырёх. `systemctl --user`
отвечает про менеджер ТОГО, КТО СПРАШИВАЕТ, а прогоны живут в менеджере пользователя
демона; юнит, о котором чужой менеджер никогда не слышал, и юнит, который кончился,
выглядят одинаково (`ActiveState=inactive`, выход 0). Поэтому команда сперва
устанавливает, ЧЕЙ это менеджер.
**И ПЯТЫЙ ОТКАЗ, добавленный 07.09: доказательство ПРОТУХЛО.** Ответ systemd снимается до
того, как транзакция берёт книгу, и мир в этом окне может уехать двумя способами: расплата
разблокировалась и `restart` открыл НОВУЮ попытку, либо ту же попытку заново заявили на
спавн. Команда несёт с ответом id попытки и счётчик заявок, транзакция сверяет оба под
блокировкой книги и отказывает при расхождении. Что делать оператору: посмотреть
`tmplatformctl runs --stalled` ещё раз и, если прогон всё ещё там, повторить команду —
проверка стоит одного повтора и существует ровно затем, чтобы юниту, поднявшемуся в этом
окне, не закрыли деньги под ним (`PD-424`).
⚠⚠ **ЧТО ИЗ ЭТОГО СЛЕДУЕТ ДЛЯ ОБОЛОЧКИ ОПЕРАТОРА: `TM_PLATFORM_STATE_DIR` ДЕПЛОЯ ДОЛЖЕН
БЫТЬ ЭКСПОРТИРОВАН, иначе команда ОТКАЖЕТ.** Каталог состояния пишут и демон, и
`ExecStopPost` каждого прогона, оба под пользователем прогонов, — поэтому его владелец и
есть тот единственный факт, по которому CLI отличает свой менеджер от чужого. Не задана
переменная или каталог принадлежит другому uid — отказ с обоими номерами и с рецептом
`systemctl --user -M <user>@ status <юнит>`. Это не придирка: без неё команда закрыла бы
деньги живого прогона по ответу, которого никто не проверял.
Второе условие проверяет уже не команда, а хранилище, внутри транзакции: прогон должен
провалить реконсиляцию столько раз подряд, сколько нужно, чтобы попасть в
`runs --stalled`. Ниже этого порога — отказ: видеть, как прогон идёт не так, и иметь
право его уничтожить — разные разрешения, и свип, который закрыл бы его правильно, ещё
работает.
**Что при этом происходит с деньгами — два случая, и они не равны.** Попытка, вообще не
дошедшая до движка, ничего не потратила: холд возвращается ЦЕЛИКОМ, а `--release-hold`
решает лишь КОГДА — сразу или на ближайшем свипе (он нужен, когда демон остановлен и
свипа не будет). Попытка, которая до движка дошла и о чём-то отчиталась, СПИСЫВАЕТСЯ по
последней цифре потока (та же арифметика, что печатает колонка SPENT), а возвращается
только остаток; здесь флаг не спрашивают — свипа, который довёл бы этот расчёт, не
будет. Попытка без базовой линии ценится нечем — холд целиком, как в settling-ветви.
**Колонка SPENT печатает `≥` перед суммой, и знак не декоративный:** цифра — счётчик
движка, а вызов, отменённый в полёте, записан в нём нулём, поэтому она НИЖНЯЯ ГРАНИЦА
(`PD-441`). Списывать холд «по ней как по цене» нельзя; строка леджера называет свой
базис словами.
✅ **Оговорка `PD-418` СНЯТА 04.09 — ветвление ушло с `runs.finished_at` на НАЛИЧИЕ
осиротевшей попытки**, то есть на то самое долговечное лечение, которое эта оговорка
называла. `run abandon` спрашивает про осиротевшую попытку ПЕРВЫМ делом и лечит её
одинаково — кончился прогон или нет. ⚠ Живой прогон при этом НЕ заканчивается и его
собственная попытка не трогается: застряли деньги ОДНОЙ попытки, а не перевод, и
заканчивать перевод из-за них значило бы отнять у пользователя работу, о которой он не
просил. Состояние по-прежнему недостижимо на не-тестовом пути (`reopen` не стартует
следующую попытку, пока холд предыдущей открыт), но документ больше не обещает того,
чего код не делает, — а если инвариант когда-нибудь ослабнет, команда уже готова.
**Строка `PHASE = settling` — та же команда, другой исход.** Прогон уже кончился,
свипа, который довёл бы его расчёт, не будет никогда, поэтому `run abandon` закрывает
деньги В СВОЕЙ транзакции независимо от флага: резервация закрывается, холд
возвращается ЦЕЛИКОМ, прогон помечается рассчитанным. Целиком — потому что не хватает
как раз движковой цифры (это и есть определение состояния), и суммы, которую платформа
могла бы обосновать, не существует; альтернатива — деньги заморожены навсегда. Статус
самого прогона НЕ переписывается: он уже закончился со своим исходом. Повторный вызов
отвечает «its money is already closed», а не «нет такого прогона».
**Прогон, чей экран замер: проекция в карантине.** Признак: гейдж
`tm_platform_quarantined_attempts` больше нуля, а в `tmplatformctl runs` у строки
непустая колонка `QUARANTINE` — причина, по которой платформа перестала читать
журнал этой попытки (строка, которую эта сборка не может прочитать: разрыв `seq`,
другой payload под тем же `seq`, чужой мажор потока, битая строка). Движок при этом
идёт и тратит холд — карантин останавливает только проекцию, свежесть экрана падает
на медленный ресинк. Деньги целы. Когда причина ушла (сборка платформы обновлена,
чужой `tmctl` в каталоге книги остановлен), проекцию возвращает человек:
```sh
tmplatformctl run unquarantine --run <id> # печатает причину и курсор, с которого свип читает дальше
```
Курсор команда НЕ трогает: следующий свип читает журнал с того места, где проекция
остановилась, и если те же байты по-прежнему нечитаемы — карантинит снова с той же
причиной, и это честный ответ, а не сбой команды. Три отказа своими словами: нет
такого прогона · у прогона нет живой попытки (законченный прогон не материализуется,
снимать нечего) · попытка не в карантине. ⚠ **Что с P13 карантин больше НЕ вызывает, и что вызывает по-прежнему** — разница в том,
удалось ли прочитать, ЧЕЙ это handshake. У попытки, **чей поток платформа именовала**,
чужой handshake (ручной `tmctl` оператора в каталоге книги, чужой мажор потока, пустой
`engine_run_id`, `seq` не 1) карантина больше не даёт: владелец распознаётся до проверки
формы, а чтение ОСТАНАВЛИВАЕТСЯ на этой строке — курсор в чужую область не переезжает,
поэтому и следующий свип не примет чужие события за наши (`PD-214`, `PD-426`, `PD-438`).
**Битая строка — по-прежнему карантин, и это по построению:** `engine_run_id` лежит
ВНУТРИ payload, значит у строки, которая не распарсилась, владельца просто нет, и
пропустить её как чужую нельзя. Карантинят и настоящие сигналы порчи НАШЕГО потока:
разрыв `seq`, другой payload под тем же `seq`, строка длиннее буфера.
**Что видно оператору у остановленной («припаркованной») попытки:** проекция стоит и
`tm_platform_tailer_lag_bytes` растёт, но свежесть НЕ теряется — платформа спрашивает
движок напрямую (`tmctl status`), как делает для карантина, и в лог идёт WARN с прогоном,
попыткой и смещением. **У парковки СВОИ три сигнала, и это не карантинные:** колонка
`PARKED` в `tmplatformctl runs` печатает, СКОЛЬКО ВРЕМЕНИ попытка стоит; гейдж
`tm_platform_parked_attempts` считает её отдельной серией; колонка `run_attempts.parked_at`
хранит момент. ⛔ Карантинных сигналов у неё по-прежнему нет и не будет: `QUARANTINE`
пуста, `tm_platform_quarantined_attempts` её не считает, `run unquarantine` отвечает «не в
карантине» — и это правильно, потому что снимать парковку не надо: её ставит и снимает один
и тот же проход свипа, тогда как карантин снимает ЧЕЛОВЕК. ⚠ **Ждать, что она сама рассосётся, НЕЛЬЗЯ:** «чужой» handshake
может писать живой процесс этой же попытки — платформа отдаёт каждому её спавну один и
тот же id потока, а движок на повторе минтит свежий, — так что прогон способен простоять
припаркованным весь свой срок. Разбор и радиус — `PD-438`.
⚠ Исключение — попытка БЕЗ `engine_run_id` (заведена сборкой до того, как платформа стала
именовать поток): она усыновляет первый встреченный handshake и потому проверяет его
полностью, так что для неё чужой мажор по-прежнему терминален для проекции — эта команда
её и возвращает.
**Книга, чью читательскую поверхность не удаётся построить.** После пяти неудач долг списывается,
книга остаётся с прежним текстом и попадает в список:
```sh
tmplatformctl books --abandoned # книга, когда сдались, что сказал движок
tmplatformctl book refresh --book <id> # попросить заново, с чистым бюджетом попыток
```
Гейдж — `tm_platform_reading_surfaces_abandoned`. Отказ хоста (движок не запускается, чужой лок,
немигрированная схема) попытку НЕ тратит: он про все книги сразу, а не про эту.
**Две ручки бюджета свипа** (обе печатаются на буте с источником):
| Переменная | Дефолт | Что это |
|---|---|---|
| `TM_PLATFORM_SWEEP_BUDGET` | `2m` | сколько всего может занять один проход свипа |
| `TM_PLATFORM_RUN_BUDGET` | `1m` | сколько может занять ОДИН прогон внутри прохода |
⚠ Поднимать надо ПАРУ, а не одну: проход делится пополам между сверкой и расчётом денег, поэтому
`RUN_BUDGET` выше половины `SWEEP_BUDGET` наблюдаемо ничего не меняет (`PD-368`).
## Дев-стенд: живая платформа для фронта (П-16)
Фронт разрабатывается против ЖИВОЙ платформы, а не против своих моков. Рецепт целиком:
```sh
# 1. Postgres без root (docs/STACK_DECISIONS.md §«Postgres на стенде без root»).
~/.local/pgsql/bin/pg_ctl -D ~/.local/share/tmstand/pgdata \
-l ~/.local/share/tmstand/pg.log -o "-k /tmp -p 55433 -c listen_addresses=''" start
# 2. Окружение стенда. ⚠ ДВА гейта на старте, оба про «стендовое окружение уехало в прод»:
# TM_PLATFORM_DEV_LOGIN вместе с OIDC — отказ; TM_PLATFORM_INSECURE_COOKIES вместе с OIDC,
# чей callback НЕ http:// — тоже отказ (боевой вход в куках без Secure и с выключенным HSTS).
# Локальный провайдер по http на стенде при этом законен и работает.
export TM_PLATFORM_DSN='postgres://postgres@/tmstand?host=/tmp&port=55433&sslmode=disable'
export TM_PLATFORM_ADDR=127.0.0.1:8080
export TM_PLATFORM_MIGRATE=1
export TM_PLATFORM_INSECURE_COOKIES=1 # дев-профиль куки: __Host- требует Secure
export TM_PLATFORM_DEV_LOGIN=dev@stand # вход без внешнего провайдера — ТОЛЬКО стенд
export TM_PLATFORM_BOOKS_DIR=$HOME/.local/share/tmstand/books
export TM_PLATFORM_STATE_DIR=$HOME/.local/share/tmstand/state
export TM_PLATFORM_ENGINE_BIN=$HOME/.local/bin/tmctl
export TM_PLATFORM_CTL_BIN=$PWD/tmplatformctl # абсолютный: systemd отвергает иной
export TM_PLATFORM_BOOK_TEMPLATE=$HOME/.local/share/tmstand/book-template.yaml
# ⚠ ОБЯЗАТЕЛЬНО, как и в бою: без хотя бы одной ДОСТУПНОЙ пары демон, принимающий загрузки, не
# стартует. Правило и его причина — блок ⚠ `TM_PLATFORM_LANGUAGE_PAIRS` выше.
export TM_PLATFORM_LANGUAGE_PAIRS='zh>ru'
mkdir -p "$TM_PLATFORM_BOOKS_DIR" "$TM_PLATFORM_STATE_DIR"
# 3. Демон (мигрирует сам при TM_PLATFORM_MIGRATE=1).
./tmplatformd & DAEMON=$!
# 3а. ⚠ ОПОЗНАТЬ ПРОЦЕСС ДО СИДА. Слушатель на порту обязан быть ЭТИМ pid: `200` от /healthz
# доказывает лишь, что по адресу кто-то есть, а сид ниже дарит деньги и грузит книгу.
sleep 1
ss -ltnp "sport = :${TM_PLATFORM_ADDR##*:}" | grep -q "pid=$DAEMON," || {
echo "на $TM_PLATFORM_ADDR отвечает НЕ этот демон (свой умер на bind?) — сид отменён"
exit 1
}
# 4. Сид: аккаунт + кредит + демо-книга ЧЕРЕЗ ЖИВОЙ ИНТЕЙК (не INSERT).
# ⚠ Адрес берётся из переменной, а не пишется вторым литералом: разъехавшиеся копии одного
# адреса — это и есть способ засеять чужой стенд, не заметив.
./tmplatformctl seed --url "http://$TM_PLATFORM_ADDR"
# 5. Фронт ходит на стенд своим дев-прокси (TM_PLATFORM на его стороне).
```
**Шаг 3а появился не из осторожности, а по замеру 04.09.** На машине разработки четвёртые сутки
жил ЧУЖОЙ `tmplatformd` на `127.0.0.1:8080` со своей базой. Демон этого рецепта умирает в такой
ситуации на `bind: address already in use` — молча, потому что запущен фоном, — и **шаг 4 уезжает
в чужую базу**: он выдаёт `$25` кредита и грузит книгу через живой интейк, то есть пишет деньги и
данные в чужой деплой, отвечающий по тому же адресу. Проверено исполнением: `/healthz` и `/readyz`
на 8080 отвечали `ok` и `ready` при мёртвом собственном демоне. ⚠ **Порт `8080` при этом остаётся
дефолтом зоны сознательно** — коллизия здесь `tmplatformd` против `tmplatformd`, и любой другой
номер даст ту же аварию на втором одновременном стенде; чинится не номер, а посылка «по адресу
отвечают — значит это мой сервис». Разбор той же поломки со стороны дев-рецепта —
`docs/STACK_DECISIONS.md` §«Как поднять локально».
Сессию в браузер выдаёт тот же вызов, что делает сид, — один раз из консоли приложения:
```js
await fetch('/auth/dev-login', {method: 'POST'})
```
**Дев-вход не должен доехать до прода, и это гарантировано ЧЕТЫРЬМЯ независимыми свойствами**
перечень и довод каждого лежат единственным носителем в `docs/STACK_DECISIONS.md` §30, разбор в
доккомментарии `internal/login/dev.go`. На каждый выданный сеанс пишется WARN.
**`TM_PLATFORM_BOOKS_DIR` — каталог, который сервис ПИШЕТ и, на отклонённом интейке, УДАЛЯЕТ.**
Всё под ним создано загрузкой книг; книга, заведённая дев-инструментом с чужим `--workdir`, туда не
попадает и не удаляется никогда (гард `books.owns`). Каталог должен существовать и принадлежать
`tmplatform`, и он обязан быть в `ReadWritePaths=` юнита — при `ProtectSystem=strict` отсутствующий
путь валит сборку mount-namespace целиком (PD-91, та же грабля, что и с `/srv/textmachine`):
```sh
install -d -m0750 -o tmplatform -g tmplatform /srv/textmachine/books
```
**`/metrics` не аутентифицирован** — его защищает только адрес привязки, и дефолт `127.0.0.1`
снаружи недостижим. Скрейпер живёт на том же хосте либо ходит через тот же edge, что и API; что
именно открывает внешний адрес и почему риск принят — `PD-179`, разбор `docs/STACK_DECISIONS.md` §24.
**Загрузка книги идёт минуты, и это касается edge-прокси.** Маршрут `POST /v0/books` принимает до
`TM_PLATFORM_MAX_UPLOAD_BYTES` (64 МиБ) и держит соединение до `TM_PLATFORM_UPLOAD_DEADLINE`
(10 минут). Прокси перед сервисом обязан разрешать столько же: у него свои `client_max_body_size` и
свои таймауты чтения, и молчаливо режет их он, а не мы.
**`TM_PLATFORM_STATE_DIR` меняется только когда живых прогонов нет.** Exit-маркер пишется по пути,
вычисленному при СПАВНЕ, а читается по пути из текущей конфигурации: после смены каталога конец
идущего прогона становится невидим, и реконсилятор перезапускает его как потерянный (PD-155). Путь
обязан быть абсолютным — относительный демон отвергает на старте, потому что маркер пишет юнит из
каталога книги, а читает демон из своего.
Миграции выкатываются ОДИН раз, не каждой репликой: `TM_PLATFORM_MIGRATE=1 tmplatformd` разово
либо отдельный шаг деплоя. `goose` держит advisory-лок, так что параллельный запуск не гонка,
но и не норма.
## Бэкап и восстановление
**До 05.09 копий не было НИ У ЧЕГО** (строка бэклога 269): весь оплаченный перевод жил в SQLite
книги на локальном диске, кредиты и холды — в Postgres, и одна потеря диска стирала оба без
возможности восстановить или доказать, кто сколько купил. Это единственная поломка деплоя **без
частичного исхода**, поэтому механизм здесь нарочно нехитрый: целые файлы, целый дамп, манифест, и
восстановление, которое оператор делает `pg_restore` и `cp`, а не нашим инструментом.
**Включается ОДНОЙ переменной, и без неё демон говорит об этом на каждом старте:**
```
no TM_PLATFORM_BACKUP_DIR: this deployment keeps NO restore point of the paid translations
or of the credit ledger; losing this host's disk loses both irrecoverably
```
⚠ **ТРЕБОВАНИЕ, КОТОРОЕ СЛУЖБА ПРОВЕРИТЬ НЕ МОЖЕТ И НЕ ПРИТВОРЯЕТСЯ: `TM_PLATFORM_BACKUP_DIR`
обязан лежать на ДРУГОМ УСТРОЙСТВЕ** (или синхронизироваться прочь с хоста — rsync/rclone по
таймеру, объектное хранилище). Каталог под `/var/lib/tmplatform` формально работает и переживает
ошибку человека, но НЕ переживает диск — то есть не закрывает то, ради чего заведён. Проверить это
из процесса нельзя (bind-mount и симлинк обманут любую пробу), поэтому это половина сделки,
исполняемая оператором.
**Каталог обязан быть записываем ВНУТРИ ПЕСОЧНИЦЫ ЮНИТА** — та же оговорка, что у `BOOKS_DIR` и
`EXPORTS_DIR`: при `ProtectSystem=strict` путь вне `StateDirectory=`/`ReadWritePaths=` недоступен на
запись. Добавьте свой путь в юнит и создайте каталог ДО первого старта (`PD-91`: отсутствующий путь
в `ReadWritePaths=` валит сборку mount-namespace целиком, юнит не стартует вовсе):
```sh
install -d -m0750 -o tmplatform -g tmplatform /var/backups/tmplatform
# в deploy/tmplatformd.service, рядом с ReadWritePaths=/srv/textmachine:
# ReadWritePaths=/var/backups/tmplatform
```
### Что такое точка восстановления
Каталог, публикуемый переименованием из `.partial-<штамп>` — то есть точка, которая ЕСТЬ, это
точка, которая ДОПИСАНА:
```
/var/backups/tmplatform/20260905T120000Z/
manifest.json версия документа, время, полный список файлов с sha256 и размером,
признак полноты и инструкции по восстановлению ВНУТРИ самой точки
postgres.dump pg_dump --format=custom всей базы (аккаунты, кредиты, холды, книги, поток)
books/<book_id>/ каталог книги, а её проектная база — СОГЛАСОВАННОЙ КОПИЕЙ под именем project.db
```
**Копию базы книги делает ДВИЖОК, а не платформа, и это не формальность двух зон.** Платформе
запрещено открывать SQLite движка (`D39.85`), и это же — правильная инженерия: живой SQLite,
скопированный побайтно во время прогона, рвётся молча. Платформа зовёт `tmctl backup`, который
сперва гоняет `PRAGMA integrity_check`, потом `VACUUM INTO` — то есть копирует то, что никто не
пишет, — и **забирает** получившийся файл к себе, чтобы каталог книги не рос на целую базу за проход.
**Чего в точке НЕТ, и это решения, а не забывчивость:** собранные экспорты (эфемерны по TTL и
пересобираются из книги) · маркеры выхода `STATE_DIR` (живут длину одного прогона) · собственный
каталог `backups/` внутри книги · **ДОТФАЙЛЫ** · живой `*.db` и его `-wal`/`-shm`.
**Дотфайлы исключены НЕ ради чистоты, а потому что это СЕКРЕТ:** конвенция движка — `.env` рядом с
`book.yaml` с ПРОВАЙДЕРСКИМИ КЛЮЧАМИ (`backend/cmd/tmctl/main.go`, греп `loadDotEnv`), а этот раздел
велит увозить каталог точек ПРОЧЬ С ХОСТА. Бэкап, уносящий ключи деплоя туда, куда уезжают бэкапы, —
это утечка учётных данных по расписанию.
**Подкаталоги книги, наоборот, копируются РЕКУРСИВНО** (кроме `backups/`): книжный оверлей
`langpack_extend` и `glossary_seed` законно живут в подкаталоге, и «полная» точка без них была бы
потерей книжного канона молча.
**Всё пропущенное ИМЕНУЕТСЯ в манифесте** (`books[].not_copied`) с причиной — чтобы через полгода
читатель не выводил правила из того, что уцелело.
**И ТОЧКА — НЕ МГНОВЕНИЕ ВРЕМЕНИ, а последовательность:** сперва снимается денежный реестр, потом
книги одна за другой. На большой библиотеке между дампом и последней книгой проходят минуты, а то и
десятки минут. `complete: true` означает «всё, что должно было попасть, попало», а НЕ «всё снято в
один момент»: прогон, начавшийся после дампа, будет виден в книге и не виден в реестре. Для
восстановления это безвредно — реконсилятор доводит расчёт по exit-маркеру и журналу, — но читать
точку как согласованный снимок нельзя.
⚠ **Про последний — точнее, потому что первая редакция этого абзаца говорила «наши копии их
замещают», и это НЕВЕРНО.** `<каталог книги>/backups/` — это ПРЕДРЕЙСОВЫЕ точки, которые движок
снимает своим гардом перед каждым платным прогоном (`backend/cmd/tmctl/backup.go`, греп
`preflightBackup`). Они отвечают на ДРУГОЙ вопрос: «откатить вот этот прогон», тогда как точка здесь
снимается по расписанию и может уже содержать то, что плохой прогон натворил. Диск они не переживают
— и в этом смысле не заменяют точку, — но и точка не заменяет их. Поэтому они не копируются (копировать
каждую прошлую копию на каждом проходе — это квадратичный рост) и не удаляются.
**И операторский факт, который стоит знать до того, как кончится диск: их не чистит НИКТО.**
Проверено грепом по обеим зонам: единственные писатели — `backupCmd` и `preflightBackup` в
`backend/cmd/tmctl/backup.go` (плюс `tmctl migrate`), удаляющего кода нет ни в движке, ни в
платформе. То есть каждый платный прогон и каждая миграция книги оставляют ПОЛНУЮ копию её базы
навсегда. Пока книг единицы, это дёшево; на большой библиотеке чистить придётся руками
(`find <BOOKS_DIR>/*/backups -name '*.db' -mtime +30`), и решение «сколько предрейсовых точек
держать» ещё не принято ни одной зоной.
**Пропущенная книга помечается пропуском, а не умалчивается:** `manifest.json` несёт `complete` и
причину по каждой книге. Дамп Postgres — наоборот: **если он не удался, точка НЕ публикуется вовсе**,
потому что копия без денежного реестра не отвечает на вопрос «кто сколько заплатил».
### Расписание, поколения, наблюдение
Точка снимается СВОЕЙ горутиной со своим тикером, а НЕ пассом реконсилятора — и это исправление,
а не вкус: такт реконсилятора последователен, поэтому копирование целых баз задержало бы расчёт
денег, ретраи интейка, сборку мусора экспортов и обновление ВСЕХ гейджей на всю свою длину. До часа
четыре раза в сутки — это не доля такта, это остановка контрол-плейна. Срок пасс решает сам, по самой
свежей точке на диске, поэтому расписание переживает рестарт без своего состояния.
Дефолт: каждые `6h`, `14` поколений = 84 ч истории.
**Пасс, не уложившийся в бюджет (`60 мин`), НЕ публикует НИЧЕГО.** Обрезать было бы хуже: книги
перечисляются от старых к новым, значит обрезанная точка теряла бы ровно те, над которыми идёт
оплаченная работа, — и при этом обнуляла бы возраст.
Метрика ровно одна и она про возраст: **`tm_platform_backup_age_seconds`** — сколько назад снята
самая свежая **ПОЛНАЯ** точка; `+Inf`, если такой нет вовсе.
**Именно ПОЛНАЯ, и это половина ценности гейджа:** точка, не сумевшая скопировать книгу,
публикуется (девять спасённых книг лучше нуля), но бэкапом ЭТОЙ книги она не является — и если бы её
штамп обнулял возраст, деплой с одной вечно некопируемой книгой выглядел бы свежезабэкапленным во
всех четырнадцати поколениях. Счётчик успехов на этот вопрос не отвечает: джоба,
падающая неделю, и джоба, которой никогда не было, выглядят в нём одинаково.
```sh
tmplatformctl backup # снять точку СЕЙЧАС (перед миграцией, перед апгрейдом)
tmplatformctl backup --list # что есть: когда, сколько книг, полна ли, сколько байт
tmplatformctl backup --verify latest # пере-хешировать точку против её манифеста
tmplatformctl backup --list --dir <путь> # то же на КОПИИ точки, без окружения деплоя
```
`--list`/`--verify` работают без DSN и без рабочего окружения намеренно: точка, увезённая на
другую машину, — всё ещё точка, и «что у нас есть» спрашивают не в лучший день.
### Восстановление — исполняется руками, командой `restore` не заменяется
Команды `restore` здесь НЕТ сознательно: восстановление затирает живую базу и живую библиотеку,
судить о полноте копии может только человек, а односложная команда для этого — односложная команда
для уничтожения текущего состояния по ошибке. Те же шаги лежат ВНУТРИ точки (`manifest.json`,
поле `notes`), чтобы пережить переезд копии подальше от этого репозитория.
```sh
# 0. Убедиться в самой копии ПЕРЕД тем, как что-то трогать.
tmplatformctl backup --verify 20260905T120000Z --dir /var/backups/tmplatform
# 1. Остановить приём: демон не должен писать в то, что восстанавливается.
systemctl stop tmplatformd
# 1а. ⚠ И ЭТОГО МАЛО: ПРОГОНЫ ДЕМОНУ НЕ ДЕТИ. Каждый — транзиентный юнит в СОБСТВЕННОМ менеджере
# пользователя `tmplatform` и переживает остановку контрол-плейна по построению (D39.106, §«Что
# юнит закрывает содержательно» выше). Восстановление делают в худший день — именно тогда
# вероятен ИДУЩИЙ оплаченный прогон, и шаг 4 подсунул бы файл под открытым дескриптором.
# Спросить и, если есть, остановить — и только потом трогать каталоги:
systemctl --user -M tmplatform@ list-units 'tm-run-*' --no-legend # что живо в ИХ менеджере
systemctl --user -M tmplatform@ stop 'tm-run-<id>.service' # каждый, что нашёлся
# ⚠ Останов прогона НЕ теряет его работу: `SIGTERM` = «дочти чанк и отпусти лок», а реконсилятор
# закроет его штатно после подъёма демона. Терять нечего, ждать — до одного чанка.
# 2. Денежный реестр и вся библиотека — одним дампом.
# ⚠ DSN в рекомендуемой раскладке лежит ФАЙЛОМ (`TM_PLATFORM_DSN_FILE=/etc/tmplatform/dsn`), а не
# переменной в шелле оператора: в худший день `$TM_PLATFORM_DSN` пуст, и шаг падает первым.
pg_restore --clean --if-exists --no-owner -d "$(cat /etc/tmplatform/dsn)" /var/backups/tmplatform/20260905T120000Z/postgres.dump
# 3. Каждая книга — на свой рабочий каталог (его называет колонка workdir и манифест точки).
cp -a /var/backups/tmplatform/20260905T120000Z/books/<book_id>/. /srv/textmachine/books/<book_id>/
# 4. ⚠ СНАЧАЛА УБРАТЬ ХВОСТЫ SQLite, ПОТОМ КЛАСТЬ БАЗУ. Точка НЕ несёт `-wal`/`-shm` (они и есть та
# несогласованность, ради которой копию делает движок), а оставшийся в каталоге старый `-wal` от
# упавшего процесса SQLite при открытии применит ПОВЕРХ восстановленной базы. На чистом хосте их
# нет; на хосте, где чинят одну книгу, — есть.
rm -f /srv/textmachine/books/<book_id>/*.db-wal /srv/textmachine/books/<book_id>/*.db-shm
# И ТОЛЬКО ТЕПЕРЬ — проектная база. В точке она лежит под ИМЕНЕМ project.db; на месте она
# обязана называться так, как её называет `project_db` в book.yaml ЭТОЙ книги, а если ключа там
# нет — <book_id>.db (движковый дефолт, backend/internal/config/book.go, греп `b.ProjectDB =`).
mv /srv/textmachine/books/<book_id>/project.db /srv/textmachine/books/<book_id>/<book_id>.db
chown -R tmplatform:tmplatform /srv/textmachine/books/<book_id>
# 5. ⚠ ОТОЗВАТЬ УЧЁТНЫЕ ДАННЫЕ, ЕСЛИ ОТЗЫВ СЛУЧИЛСЯ ПОСЛЕ СНЯТИЯ ТОЧКИ. Дамп несёт таблицу сессий
# целиком, поэтому восстановление ВОСКРЕШАЕТ всё, что было живо в момент снятия — включая
# браузерную сессию или bearer-токен, отозванные позже. Это не дефект бэкапа (копия обязана быть
# копией), это шаг чек-листа:
tmplatformctl revoke --user <id> # для каждого, кому отзывали доступ после штампа точки
# 6. Поднять и проверить: баланс на месте, книга на месте, движок открывает её файл.
systemctl start tmplatformd
tmplatformctl balance --user <id>
/opt/textmachine/engine/<версия>/tmctl status --config /srv/textmachine/books/<book_id>/book.yaml
```
**Шаг 4 — единственный, где оператор должен посмотреть в `book.yaml`.** Платформа НЕ выводит имя
проектной базы сама (закон шва п.1 запрещает ей выводить чужие пути), поэтому и точка хранит копию
под нейтральным именем, а не угадывает исходное.
**Восстановление предъявлено ИСПОЛНЕНИЕМ и запинено тестом, а не описано:**
`internal/backup/backup_live_test.go` `TestARestorePointCanActuallyBeRestored` снимает точку
настоящим `pg_dump`, восстанавливает её в базу, которая этого деплоя никогда не видела, и читает
оттуда БАЛАНС и КНИГУ. Гейт — `TM_PLATFORM_TEST_DSN` плюс `pg_dump`/`pg_restore` на `PATH`.
**Мажор `pg_dump` обязан совпадать с мажором сервера** — иначе инструмент отказывается снимать
дамп с сервера новее себя, а симптом («бэкапы падают») никто не связывает с апгрейдом Postgres,
случившимся месяцем раньше. На стенде из рецепта ниже бинарь лежит в `~/.local/pgsql/bin`, и его
надо назвать явно через `TM_PLATFORM_PGDUMP_BIN`.
## Где на сервере лежат книги
`/srv/textmachine` — корень библиотеки НА СЕРВЕРЕ. Решение владельца «книги живут в `~/books`»
относится к машине разработки: под этим юнитом домашние каталоги недоступны и детям-`tmctl` тоже
(`ProtectHome=yes`, проверено живым прогоном — §«Файлы»), поэтому библиотека под домашним каталогом
на сервере не «сработает медленнее», а НЕ НАЙДЁТСЯ. Кому это нужно, юнит называет ровно две строки
замены (`ProtectHome=tmpfs` + `BindPaths=`); других изменений не требуется.
## Дверь для не-браузерного клиента
Контракт объявляет `bearerToken` с первой версии, и сервер его ПРИНИМАЕТ на каждом `/v0`-маршруте
(`internal/auth/middleware.go`, `Present`), но до 05.09 выдать его было нечем — канон так о себе и
писал. Практическое следствие: CLI, десктоп, интеграция и смоук боевого хоста войти не могли ВООБЩЕ,
а единственная работавшая дверь — `/auth/dev-login` — прод-конфигурацией **запрещена**: демон с
настоящим OIDC и дев-входом рядом не стартует (`internal/config/config.go`, греп `DEV_LOGIN is a
development sign-in`).
```sh
# ⚠ Запускать С ОКРУЖЕНИЕМ ДЕПЛОЯ (тем же `EnvironmentFile=`, что у юнита): сроки сессии команда
# читает из СВОЕГО окружения, и без него токен получит дефолты, а не политику этого деплоя.
# Команда ПЕЧАТАЕТ применённые сроки — сверяйте их глазами, это и есть проверка.
tmplatformctl token issue --user <id> [--client <слово>] > /root/token.txt
# ПЕРВАЯ строка файла — токен; дальше комментарии: аккаунт, сроки, как слать.
# ⚠ НЕ `curl -H "Authorization: Bearer $TOKEN"`: аргументы процесса читает любой на хосте
# (/proc/<pid>/cmdline), и это тот же класс, из-за которого DSN не едет в argv у pg_dump.
# Заголовок подаётся файлом:
printf 'Authorization: Bearer %s' "$(head -1 /root/token.txt)" > /root/hdr && chmod 0400 /root/hdr
curl -H @/root/hdr https://app.example.org/v0/books
tmplatformctl revoke --user <id> # отзыв — тот же, что у браузерной сессии
```
**Это ОБЫЧНАЯ сессия, а не второй сорт credential'а:** те же 256 бит из `crypto/rand`, в БД только
sha256, те же два срока (idle скользит, absolute нет), тот же немедленный отзыв, та же строка в
журнале входов (`tmplatformctl logins --user`, провайдер `operator`). Отдельный «API-ключ» был бы
второй моделью сессии со своей политикой сроков и своим путём отзыва — а первое, что забывают
построить для второй модели, это отзыв.
**Токен печатается ОДИН раз и не восстанавливается:** хранится только его дайджест, и это ровно то
свойство, из-за которого украденный дамп базы не даёт рабочего доступа. Потерянный токен —
выпускается заново, не ищется.
**Почему выдача живёт в админ-CLI, а не ручкой HTTP.** Она не даёт полномочий, которых у оператора
не было: этим же инструментом он пишет денежный реестр и отзывает сессии. Ручка же была бы НОВЫМ
полномочием, достижимым по сети, и потребовала бы минора канона ради маршрута, который до разморозки
фронта некому нажать. Браузерная половина («создать токен» кнопкой) строится вместе с фронтом.
## Чего здесь ещё нет
TLS и домен (перед юнитом предполагается edge-прокси), ограничитель соединений на edge,
ротация логов. ⚠ **Бэкапы из этого списка УШЛИ 05.09** — раздел «Бэкап и восстановление» выше;
осталось операторское: увозить каталог точек прочь с хоста и следить за
`tm_platform_backup_age_seconds`.