# Развёртывание платформы Одна 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/` даёт `Permission denied`; `ProtectHome=tmpfs` + `BindPaths=<каталог>` → каталог виден. Ресурсные потолки (`MemoryMax=80%`, `OOMPolicy=continue`) живьём не проверялись — вывод из `systemd.resource-control(5)`/`systemd.service(5)`. ## Откат релиза: не ниже версии 5 `goose down` до версии 4 и ниже НЕ РАБОТАЕТ на живой базе: down-путь `00005` восстанавливает `users_email_key` и `email NOT NULL`, а обе формы нарушают строки, которые пишет боевой код (неподтверждённая личность даёт `email = NULL`; один адрес законно принадлежит двум аккаунтам). Откат транзакционный, поэтому падение ничего не портит — но планировать откат ниже 5 нельзя, план отката — накатить вперёд. Разбор: `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`** больше, чем дренаж платформы (15 с) плюс grace движка (30 с). Меньше — и systemd прибьёт `tmctl` посреди остановки, оставив лок проекта. - **Секреты через `LoadCredential=`,** а не через окружение: переменная окружения видна в `/proc//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 # ⚠ БЕЗ ЭТОГО НИ ОДИН ПРОГОН НЕ СТАРТУЕТ. Прогоны — транзиентные юниты в пользовательском # менеджере `tmplatform`, а он существует вне сессии входа только при включённом linger. # Проверка: `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' '' > /etc/tmplatform/oidc_client_secret && chmod 0400 /etc/tmplatform/oidc_client_secret ``` `/etc/tmplatform/env` — несекретное окружение: ``` 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. БЕЗ ЭТИХ ЧЕТЫРЁХ ИНСТАНС НЕ ЗАПУСТИТ НИ ОДНОГО ПЕРЕВОДА, и это не # «неполный пример», а окружение, при котором каждый ОПЛАЧЕННЫЙ прогон падает. TM_PLATFORM_ENGINE_BIN=/opt/textmachine/engine/<версия>/tmctl # пусто = инстанс только читает TM_PLATFORM_CTL_BIN=/opt/textmachine/bin/tmplatformctl # его зовёт юнит как ExecStopPost TM_PLATFORM_STATE_DIR=/var/lib/tmplatform/state # маркеры выхода; ТОЛЬКО абсолютный TM_PLATFORM_ENGINE_KEYS_PATH=/etc/tmplatform/engine-keys # KEY=VALUE, едет --keys-file ``` ⚠ **Почему каждая из четырёх обязательна, а не желательна:** - **`ENGINE_BIN`** пусто — инстанс объявляет себя читающей репликой и не спавнит ничего; - **`CTL_BIN`** — путь, который юнит зовёт в `ExecStopPost`, чтобы записать маркер выхода; без него прогон завершается, а платформа об этом не узнаёт никогда; - **`STATE_DIR`** — каталог маркеров. ⚠ Дефолт лежит ВНЕ `ReadWritePaths=` юнита, а `deploy/` ставит `ProtectSystem=strict`, поэтому без явного значения запись маркера запрещена файловой системой, а не логикой; - **`ENGINE_KEYS_PATH`** — ЕДИНСТВЕННЫЙ канал провайдерских ключей в движок (едет аргументом `--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 с причиной. Починили файл — ближайший свип разбирает всё накопившееся. ## Апгрейд ПЛАТФОРМЫ: миграция и бинарь едут вместе ⚠ **Схема и бинарь платформы — один шаг, а не два.** Любая миграция, снимающая колонку, делает перекрытие версий невозможным: инстанс старой сборки рядом с уже мигрированной базой отвечает 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 ` делает пред-миграционный бэкап и переводит 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` после пяти попыток. Симптом — интейк массово отклоняет при здоровом на вид движке; лечение — выкатить платформенный билд, знающий новую форму. Правило то же, что у схемы хранилища: **сначала платформа, потом движок**. Порядок (действующий). ⚠ Ключевое: **`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 --reason "почему" [--release-hold] ``` Команда ОТКАЖЕТ, если попытка ещё называет юнит или несёт базовую линию траты, — закрывать прогон над живым движком нельзя. `--reason` обязателен: это единственная запись о том, почему оплаченный прогон объявлен законченным. Холд возвращается ЦЕЛИКОМ в обоих случаях (к abandon допускается только попытка, не дошедшая до движка, значит она ничего не потратила); флаг решает лишь КОГДА — сразу или на ближайшем свипе. Он нужен, когда демон остановлен и свипа не будет. ⚠ **Оговорка `PD-418`: ветвление `run abandon` идёт по `runs.finished_at`, а не по наличию осиротевшей попытки.** Значит команда лечит `settling`-строку только у прогона, который УЖЕ кончился. Живой прогон с нерассчитанной ПРЕДЫДУЩЕЙ попыткой ушёл бы в живую ветку: осиротевший холд команда не тронет, а ответит про процесс. Сегодня это состояние недостижимо — единственный не-тестовый путь ко второй открытой резервации, `reopen`, отказывается стартовать следующую попытку, пока холд предыдущей открыт, — и пак P12 его достижимее НЕ сделал (блокированная расплата теперь считается неудачей и видна, но рестарт по-прежнему не происходит, `PD-424`). Оговорка стоит здесь, потому что документ иначе обещает оператору то, чего код не делает, и разойдётся заметно, если этот инвариант когда-нибудь ослабнет. Долговечное лечение — ветвить по НАЛИЧИЮ осиротевшей попытки вместо `finished_at`, как уже делает settling-ветвь `StalledRuns`. **Строка `PHASE = settling` — та же команда, другой исход.** Прогон уже кончился, свипа, который довёл бы его расчёт, не будет никогда, поэтому `run abandon` закрывает деньги В СВОЕЙ транзакции независимо от флага: резервация закрывается, холд возвращается ЦЕЛИКОМ, прогон помечается рассчитанным. Целиком — потому что не хватает как раз движковой цифры (это и есть определение состояния), и суммы, которую платформа могла бы обосновать, не существует; альтернатива — деньги заморожены навсегда. Статус самого прогона НЕ переписывается: он уже закончился со своим исходом. Повторный вызов отвечает «its money is already closed», а не «нет такого прогона». **Книга, чью читательскую поверхность не удаётся построить.** После пяти неудач долг списывается, книга остаётся с прежним текстом и попадает в список: ```sh tmplatformctl books --abandoned # книга, когда сдались, что сказал движок tmplatformctl book refresh --book # попросить заново, с чистым бюджетом попыток ``` Гейдж — `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 & # 4. Сид: аккаунт + кредит + демо-книга ЧЕРЕЗ ЖИВОЙ ИНТЕЙК (не INSERT). ./tmplatformctl seed --url http://127.0.0.1:8080 # 5. Фронт ходит на стенд своим дев-прокси (TM_PLATFORM на его стороне). ``` Сессию в браузер выдаёт тот же вызов, что делает сид, — один раз из консоли приложения: ```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-лок, так что параллельный запуск не гонка, но и не норма. ## Где на сервере лежат книги `/srv/textmachine` — корень библиотеки НА СЕРВЕРЕ. Решение владельца «книги живут в `~/books`» относится к машине разработки: под этим юнитом домашние каталоги недоступны и детям-`tmctl` тоже (`ProtectHome=yes`, проверено живым прогоном — §«Файлы»), поэтому библиотека под домашним каталогом на сервере не «сработает медленнее», а НЕ НАЙДЁТСЯ. Кому это нужно, юнит называет ровно две строки замены (`ProtectHome=tmpfs` + `BindPaths=`); других изменений не требуется. ## Чего здесь ещё нет TLS и домен (перед юнитом предполагается edge-прокси), ограничитель соединений на edge, ротация логов, бэкап Postgres. Всё это — работа с первым реальным деплоем, не раньше.