docs(deployment): ORCH-10a Lite-тираж — LITE_SETUP.md + канон watchdog-конфига + анти-дрейф контур

Закрывает Type A эпика ORCH-10 (поверх 10-common ORCH-101). Docs+tests
(паттерн ORCH-077/092): src/**, docker-compose.yml, Dockerfile, scripts/** —
ноль изменений; конвейер (STAGE_TRANSITIONS/QG_CHECKS/check_*/machine-verdict/
схема БД) — байт-в-байт.

- docs/deployment/LITE_SETUP.md (D1/D2): golden source Lite-тиража — 13
  нормативных разделов в порядке маршрута оператора, каждый шаг =
  fenced-команда + явная «Проверка:»/PASS/FAIL, хост-специфика только
  плейсхолдерами; канон не форкается (статусы/env/вебхуки/smoke — ссылками
  на ONBOARDING §1 / REPLICATION §2–§4 / SETUP_WEBHOOKS; явно — только
  fail-closed Confirm Deploy/STOP и обязательные ключи нового хоста).
- .env.watchdog.example (D5, исход А-4): третий канонический env-example;
  key-set = блок WATCHDOG_* .env.example (19 ключей, токены — пустые
  плейсхолдеры); закрывает ловушку файла-носителя (sidecar читает ТОЛЬКО
  .env.watchdog); C-1 ORCH-100 + когерентность порта в шапке; .env.watchdog
  добавлен в .gitignore (секрет-гигиена, зеркало .env.staging).
- tests/test_lite_setup_doc.py (D8): 25 структурных тестов без
  сети/LLM/subprocess — 13 разделов в порядке D2, кирпичи FR-6.1, key-sync
  watchdog-канона, env-ключи ⊂ .env.example, compose-подмножество (ровно
  орк+watchdog по дефолту, staging за профилем, анти-появление
  plane*/gitea*), fenced-скан FORBIDDEN (импорт из test_no_host_hardcodes)
  + секрет-эвристика с негативным самочеком, «22 статуса» сверкой импорта
  plane_sync._PLANE_NAME_TO_KEY, перекрёстность.
- Перекрёстные доки (FR-7): REPLICATION.md §1 (Type A — Lite →  ORCH-102 +
  ссылка), README.md (способность Lite + docs/deployment/ в структуре),
  INFRA.md (.env.watchdog в секрет-нормативе + ссылка на deployment),
  CLAUDE.md (блок ORCH-102), CHANGELOG.md.

Нормативы разделов: Gitea — branch protection на main НЕ включать (D3 /
ADR D10 ORCH-009 / INV-4), pre-receive не вводится, ОДИН глобальный
webhook-секрет; staging-вилка опциональна (D6); источник кода —
параметризованный git clone <ORCHESTRATOR_GIT_URL> (D7); stateless —
данные/задачи/секреты боевого хоста НЕ переносятся (AC-3).

Тесты: pytest tests/ -q — 1789 passed (полный регресс зелёный).

Refs: ORCH-102

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 22:18:48 +03:00
committed by orchestrator-deployer
parent 443ddc6b6f
commit 8351e91382
9 changed files with 1111 additions and 2 deletions

42
.env.watchdog.example Normal file
View File

@@ -0,0 +1,42 @@
# .env.watchdog — конфигурация sidecar-watchdog (контейнер orchestrator-watchdog).
# Канонический example (ORCH-102, ADR-001 D5; симметрия .env.example/.env.staging.example).
#
# ⚠️ СЕМАНТИКА ФАЙЛА-НОСИТЕЛЯ: sidecar-контейнер читает ТОЛЬКО этот файл
# (compose: env_file {path: .env.watchdog, required: false}). Ключ WATCHDOG_*,
# положенный в .env, для sidecar ИНЕРТЕН (его видит лишь контейнер орка).
# Отсутствие файла НЕ ломает `docker compose up` (required: false); нет токена →
# fail-safe: watchdog пишет алерты в логи, но не отправляет.
#
# Создание на хосте: cp .env.watchdog.example .env.watchdog → заполнить два токена.
# DO NOT COMMIT реальный .env.watchdog — этот файл только шаблон (зеркало
# .env.staging.example); реальные значения живут на хосте.
#
# Нормативы:
# * C-1 (ORCH-100): у watchdog СВОЙ Telegram-бот — независимый канал алертов.
# Переиспользовать токен орка (ORCH_TELEGRAM_BOT_TOKEN) ЗАПРЕЩЕНО: упавший
# орк не сможет сообщить о себе своим же ботом.
# * Когерентность порта: WATCHDOG_METRICS_URL следует за прод-портом
# (ORCH_DEPLOY_PROD_TARGET_PORT) — сменил порт орка → обнови URL здесь.
# * Key-set этого файла = блок WATCHDOG_* в .env.example (канон ключей);
# синхронность держит tests/test_lite_setup_doc.py (key-sync, TC-02b).
# Значения = дефолты watchdog/config.py.
WATCHDOG_ENABLED=true
WATCHDOG_INTERVAL_S=30
WATCHDOG_HTTP_TIMEOUT_S=5
WATCHDOG_COOLDOWN_S=1800
WATCHDOG_METRICS_URL=http://127.0.0.1:8500/metrics
WATCHDOG_ORCH_DOWN_TICKS=3
WATCHDOG_MEM_PCT=90
WATCHDOG_DISK_CRIT_ENABLED=false
WATCHDOG_DISK_CRIT_PCT=97
WATCHDOG_DISK_PATHS=/repos,/app/data
WATCHDOG_AGENT_HUNG_MIN=20
WATCHDOG_AGENT_CPU_FLOOR=0.01
WATCHDOG_STAGE_STUCK_MIN=120
WATCHDOG_QUEUE_DEPTH=20
WATCHDOG_CONTAINERS=orchestrator
WATCHDOG_DOCKER_SOCK=/var/run/docker.sock
WATCHDOG_DEPS=
WATCHDOG_TG_BOT_TOKEN=
WATCHDOG_TG_CHAT_ID=

2
.gitignore vendored
View File

@@ -7,5 +7,7 @@ data/
.pytest_cache/
# ORCH-31: staging env (secrets, not committed — see .env.staging.example)
.env.staging
# ORCH-102: sidecar-watchdog env (secrets, not committed — see .env.watchdog.example)
.env.watchdog
# ORCH-31: staging DB data directory
data/staging/

View File

@@ -3,6 +3,12 @@
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
## [Unreleased]
- **ORCH-10a Lite-тираж: инструкция LITE_SETUP + канон watchdog-конфига + анти-дрейф контур** (ORCH-102, `docs`): закрыт Type A эпика ORCH-10 — заказчик разворачивает у себя **только орк+watchdog** и донастраивает окружение (Plane/Gitea/Telegram/LLM) по одной сквозной инструкции «голый хост → работающий конвейер». **Docs+tests** (паттерн ORCH-077/092): `src/**`/`docker-compose.yml`/`Dockerfile`/`scripts/**` — ноль изменений; конвейер (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД) — байт-в-байт. ADR: `docs/work-items/ORCH-102/06-adr/ADR-001-lite-setup-doc-canon.md`, сквозной `adr-0037-lite-replication-canon.md`.
- **Главный продукт (D1/D2):** новый docs-раздел `docs/deployment/` (витрина тиража, читатель — внешний оператор) с golden source `docs/deployment/LITE_SETUP.md` — 13 нормативных разделов в порядке маршрута оператора (рамка → предусловия хоста → перенос кода → конфигурация → Plane → Gitea → LLM → Telegram → запуск → регистрация проекта → smoke → stateless-проверка → траблшутинг ×7); каждый шаг = fenced-команда + явная «Проверка:»/PASS/FAIL; хост-специфика — только плейсхолдеры `<...>`/`$ENV_VAR`; канон не форкается — статусы/env/вебхуки/smoke ссылками на ONBOARDING §1 / REPLICATION §2§4 / SETUP_WEBHOOKS.
- **Канон watchdog-конфига (D5, исход А-4):** новый `.env.watchdog.example` (третий env-example; key-set = блок `WATCHDOG_*` `.env.example`, 19 ключей, токены — пустые плейсхолдеры) закрывает ловушку файла-носителя: sidecar читает ТОЛЬКО `.env.watchdog`, ключ `WATCHDOG_*` в `.env` для него инертен; шапка несёт C-1 (ORCH-100: свой бот, токен орка переиспользовать запрещено) и когерентность порта `WATCHDOG_METRICS_URL``ORCH_DEPLOY_PROD_TARGET_PORT`; `.env.watchdog` добавлен в `.gitignore` (секрет-гигиена, зеркало `.env.staging`).
- **Нормативы разделов:** Gitea (D3, исход А-1) — branch protection на `main` НЕ включать (ADR D10 ORCH-009: ломает PR-merge API merge-актора → ложные HOLD; INV-4), pre-receive хуки платформа не вводит, ОДИН глобальный webhook-секрет на все репо; staging-вилка (D6, исход А-5) — базовый Lite-контур БЕЗ staging (нужен только под self-hosting развитие платформы); источник кода (D7, исход А-6) — параметризованный `git clone <ORCHESTRATOR_GIT_URL>`; stateless (AC-3) — пустая БД при первом старте, секреты только свежевыпущенные `gen_secrets.py`, явная проверка чистоты через `GET /queue`.
- **Анти-дрейф контур (D8):** новый `tests/test_lite_setup_doc.py` (25 структурных тестов, без сети/LLM/subprocess) — 13 разделов в порядке D2; обязательные кирпичи; key-sync `.env.watchdog.example``.env.example`; каждый упомянутый env-ключ существует в каноне; compose-подмножество (ровно 3 сервиса, staging строго за `profiles: [staging]`, дефолтный `up -d` = ровно орк+watchdog, анти-появление `plane*`/`gitea*`); fenced-скан боевых литералов (импорт `FORBIDDEN` из `test_no_host_hardcodes.py` — один источник истины) + эвристика секретоподобных значений с негативным самочеком; сверка «22 статуса» импортом `plane_sync._PLANE_NAME_TO_KEY`; перекрёстность REPLICATION→LITE_SETUP + CHANGELOG.
- **Перекрёстные доки (FR-7):** REPLICATION.md §1 — строка «Type A — Lite» → ✅ ORCH-102 + ссылка; README.md — способность Lite-тиража + `docs/deployment/` в структуре; INFRA.md — `.env.watchdog` в секрет-нормативе + ссылка на deployment-раздел; CLAUDE.md — блок ORCH-102.
- **Фундамент тиража 10-common: расхардкод хоста + секреты нового хоста + smoke-процедура** (ORCH-101, `feat`): платформа разворачивается на новой инфре **без правки кода** — только env/конфиг (эпик ORCH-10, критический путь обоих типов A Lite / B Bundled; stateless по решению 10.06). Конвейер (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД) — байт-в-байт не тронут; **каждый дефолт = боевому значению** → пустой/неизменённый `.env` ⇒ поведение 1:1 (kill-switch-природа, отдельный флаг не вводится — NFR-2; enduro не затронут). ADR: `docs/work-items/ORCH-101/06-adr/ADR-001-host-parametrization-secrets-smoke.md`, сквозной `adr-0036-replication-foundation-host-parametrization.md`.
- **Расхардкод (D2, FR-1/FR-2):** четыре код-блокера закрыты тремя новыми `Settings`-ключами + реюзом существующих: `agent_home_dir` (`ORCH_AGENT_HOME_DIR`, HOME всех акторских env), `agent_git_name`/`git_email_domain` (`ORCH_AGENT_GIT_NAME`/`ORCH_GIT_EMAIL_DOMAIN`, git-идентичность: агенты — `claude-bot@<домен>` через единый `launcher.agent_git_env()` ×2 места; системные акторы держат платформенные имена `deploy-finalizer`/`post-deploy-monitor` под тем же доменом). `plane_sync.notify_stage_change` строит ссылки Branch/PR из `gitea_public_url`(fallback `gitea_url`)+`gitea_owner` вместо литералов `git.mva154.duckdns.org`/`admin`. `SELF_HOSTING_REPO`**нормативная платформенная константа** тиража (D3: конфиг-ключ превращал бы опечатку в активацию деплой-машинерии на чужом репо или тихое выключение всех self-гейтов), пин-тест.
- **Staging-порт + исполняемый инвариант ORCH-058 (D4):** `_STAGING_PORT` → ключ `staging_port` (`ORCH_STAGING_PORT`, дефолт 8501; то же имя интерполируется в compose `command:` staging — один факт, одно имя); в начале freshness-пути новый **fail-closed guard**: `staging_port == deploy_prod_target_port` → отказ «staging rebuild refused» + Telegram-алерт, **без тихого fallback** — анти-prod-гарантия из подразумеваемой константы стала исполняемой. Имена сервисов/профиля остаются константами.

View File

@@ -321,6 +321,35 @@ API → `manual-step` (fail-safe); **runbook** `docs/operations/ONBOARDING.md` (
`docs/work-items/ORCH-101/06-adr/ADR-001-host-parametrization-secrets-smoke.md`, сквозной
`docs/architecture/adr/adr-0036-replication-foundation-host-parametrization.md`.
## Lite-тираж: орк+watchdog на инфре заказчика (ORCH-102)
Закрыт **Type A** эпика ORCH-10 (поверх фундамента 10-common ORCH-101): заказчик разворачивает
у себя ТОЛЬКО `orchestrator`+`orchestrator-watchdog` и донастраивает окружение
(Plane/Gitea/Telegram/LLM — его инсталляции) по одной сквозной инструкции. **Docs+tests**
(паттерн ORCH-077/092): `src/**`/compose/Dockerfile/`scripts/**` не тронуты; конвейер байт-в-байт.
- **Golden source** — `docs/deployment/LITE_SETUP.md` (новый раздел `docs/deployment/` — витрина
тиража, читатель — внешний оператор; vs `docs/operations/` — эксплуатация НАШЕГО прода): 13
нормативных разделов в порядке маршрута оператора, каждый шаг = fenced-команда + явная
«Проверка:»/PASS/FAIL, хост-специфика только плейсхолдерами; канон не форкается — статусы/env/
вебхуки/smoke ссылками на ONBOARDING §1 / REPLICATION §2§4 / SETUP_WEBHOOKS (явно в доке —
только fail-closed имена `Confirm Deploy`/`STOP` и обязательные ключи нового хоста).
- **Канон watchdog-конфига** — новый `.env.watchdog.example` (key-set = блок `WATCHDOG_*`
`.env.example`, держится key-sync тестом): sidecar читает ТОЛЬКО `.env.watchdog`, ключ
`WATCHDOG_*` в `.env` для него инертен (ловушка файла-носителя закрыта); C-1 ORCH-100 — свой
бот, токен орка не переиспользовать; `.env.watchdog` в `.gitignore`.
- **Нормативы:** Gitea — branch protection на `main` НЕ включать (ADR D10 ORCH-009 / INV-4),
pre-receive не вводится, ОДИН глобальный webhook-секрет; compose НЕ форкается (дефолтный
`up -d` = ровно орк+watchdog, staging строго за `profiles: [staging]` — вилка только под
self-hosting развитие платформы); stateless — данные/задачи/секреты боевого хоста НЕ
переносятся, проверка чистоты через `GET /queue`.
- **Анти-дрейф** — `tests/test_lite_setup_doc.py` (структурный, без сети/LLM/subprocess):
13 разделов в порядке, кирпичи, env-ключи ⊂ `.env.example`, compose-подмножество
(анти-появление `plane*`/`gitea*`), fenced-скан `FORBIDDEN` (импорт из
`test_no_host_hardcodes.py`) + секрет-эвристика, «22 статуса» сверкой импорта
`plane_sync._PLANE_NAME_TO_KEY`, перекрёстность REPLICATION→LITE_SETUP. **Норматив
сопровождения (NFR-5):** меняешь шаги тиража → обнови LITE_SETUP.md в том же PR. Детали —
`docs/work-items/ORCH-102/06-adr/ADR-001-lite-setup-doc-canon.md`, сквозной
`docs/architecture/adr/adr-0037-lite-replication-canon.md`.
## Конвенции
- Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`)
- Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug`

View File

@@ -70,6 +70,8 @@ data/
└── runs/ # Agent output logs ({run_id}.log)
docs/
├── PRODUCT_VISION.md # Видение продукта
├── deployment/
│ └── LITE_SETUP.md # Lite-тираж: орк+watchdog на инфре заказчика (ORCH-102)
├── architecture/
│ ├── README.md # Обзор архитектуры, компоненты, API
│ ├── internals.md # Схема БД, потоки, resilience-слой
@@ -151,6 +153,10 @@ uvicorn src.main:app --reload --port 8500
| `ORCH_RUN_UID` / `ORCH_RUN_GID` / `ORCH_DOCKER_GID` | ORCH-101: uid:gid контейнера и gid docker-группы (`group_add`, ORCH-040) | `1000`/`1000`/`999` |
Тираж платформы на новый хост (полная карта, секреты, smoke) — `docs/operations/REPLICATION.md` (ORCH-101).
**Lite-тираж под ключ (ORCH-102):** разворачивание орк+watchdog на инфраструктуре заказчика
по одной сквозной инструкции «голый хост → работающий конвейер» (Plane/Gitea/Telegram/LLM
заказчик ставит сам и подключает по шагам) — `docs/deployment/LITE_SETUP.md`; канон конфига
sidecar-watchdog — `.env.watchdog.example`; анти-дрейф — `tests/test_lite_setup_doc.py`.
## Очередь задач (ORCH-1 / F-2b)

View File

@@ -0,0 +1,596 @@
# LITE_SETUP — Lite-тираж: оркестратор + watchdog на вашей инфраструктуре (ORCH-102)
> **Golden source Lite-тиража (Type A эпика ORCH-10).** Сквозной маршрут
> «голый хост → работающий конвейер» для внешнего оператора/заказчика. Каждый шаг —
> исполняемая команда + явная проверка результата (**Проверка:** / PASS / FAIL).
> Хост-специфика в командах — только плейсхолдеры `<...>` и `$ENV_VAR`.
> Тираж **stateless**: данные/задачи/секреты исходного (боевого) хоста **НЕ переносятся**
> ни на одном шаге — всё создаётся заново (§12).
---
## 1. Рамка Lite
**Что разворачиваем:** два контейнера платформы — `orchestrator` (конвейер, порт
`ORCH_DEPLOY_PROD_TARGET_PORT`, дефолт 8500) и `orchestrator-watchdog`
(независимый sidecar-мониторинг). Третий сервис `orchestrator-staging` существует в том же
compose-файле, но строго за профилем `staging` и в базовом Lite-контуре не поднимается (§9).
**Что заказчик ставит и администрирует сам** (вне этой инструкции — как продукты):
- **Plane** (task-management) — своя инсталляция; здесь только её *подключение* (§5);
- **Gitea** (git-хостинг) — своя инсталляция; здесь только её *подключение* (§6);
- **Telegram-боты** (нотификации) — свои боты (§8);
- **LLM-доступ** (claude CLI + node) — свой дистрибутив и аутентификация (§7).
**Границы слоёв тиража** (10-common vs Lite vs Bundled) — `docs/operations/REPLICATION.md` §1.
Этот док собирает кирпичи 10-common (карта env, секреты, smoke) в один маршрут и не форкает их.
**Платформенные конвенции (не менять):**
- репо платформы обязан называться **`orchestrator`** (узел безопасности `SELF_HOSTING_REPO`);
- имена compose-сервисов/профиля (`orchestrator`, `orchestrator-watchdog`,
`orchestrator-staging`, профиль `staging`) — константы платформы;
- контейнерные пути (`/app/data`, `/repos`, `/opt/claude-code`) — layout контейнера,
не хост-значения; не параметризуются.
---
## 2. Предусловия хоста
Поддерживаемый контур: **Linux x86_64, Docker Engine + Compose v2, git, python3, node** +
дистрибутив claude-code на хосте. Вне контура — вне гарантии Lite. Каждое предусловие —
команда проверки; все проверки PASS → можно переходить к §3.
**2.1. ОС и базовые зависимости.**
```bash
uname -sm # Linux x86_64
docker --version # Docker Engine
docker compose version # Compose v2
git --version
python3 --version # 3.x (для scripts/*.py)
node --version # путь к бинарю станет ORCH_HOST_NODE_BIN
```
**Проверка:** все команды отвечают версиями без ошибок — PASS; любая отсутствует — FAIL
(доставить пакет средствами вашего дистрибутива).
**2.2. Пользователь-владелец и uid/gid (инвариант ORCH-040).** Контейнеры бегут под
`ORCH_RUN_UID:ORCH_RUN_GID` — это обязан быть uid:gid владельца каталога репозиториев
`ORCH_HOST_REPOS_DIR`, иначе git-артефакты конвейера не запишутся.
```bash
id -u <deploy-user>; id -g <deploy-user> # значения для ORCH_RUN_UID / ORCH_RUN_GID
mkdir -p <путь-каталога-репозиториев> # станет ORCH_HOST_REPOS_DIR
stat -c '%u:%g' <путь-каталога-репозиториев> # обязан совпасть с ORCH_RUN_UID:ORCH_RUN_GID
```
**Проверка:** uid:gid владельца каталога = будущие `ORCH_RUN_UID`/`ORCH_RUN_GID` — PASS.
**2.3. Группа docker (доступ к docker.sock).**
```bash
getent group docker # третье поле — gid, станет ORCH_DOCKER_GID
```
**Проверка:** строка вида `docker:x:<gid>:...` есть — PASS; группы нет — FAIL
(установите Docker корректно / создайте группу).
**2.4. Каталог ssh-ключей деплой-хука** (понадобится для git-push артефактов агентов и
деплой-хуков; монтируется в `$HOME/.ssh` акторов).
```bash
mkdir -p <путь-ssh-каталога> # станет ORCH_HOST_SSH_DIR
ssh-keygen -t ed25519 -f <путь-ssh-каталога>/id_ed25519 -N "" -C "orchestrator@<host>"
ls -l <путь-ssh-каталога> # ключи читаемы пользователем из 2.2
```
**Проверка:** каталог существует, ключи на месте, владелец — пользователь из 2.2 — PASS.
Публичный ключ добавьте в Gitea (Settings → SSH Keys) на шаге §6.
**2.5. Свободные порты** (дефолты платформы: 8500 — прод, 8501 — staging).
```bash
ss -ltn | grep -E ':(8500|8501)\b' || echo "ports free"
```
**Проверка:** вывод `ports free` — PASS. Порты заняты → выберите другие и на шаге §4
синхронно задайте `ORCH_DEPLOY_PROD_TARGET_PORT``WATCHDOG_METRICS_URL`
`ORCH_POST_DEPLOY_BASE_URL``ORCH_STAGING_PORT` ≠ прод-порт — guard ORCH-058 fail-closed).
---
## 3. Перенос кода
Переносится **только код** — чекаут репо `orchestrator`. **НИКАКИХ** данных, БД или `.env`
с исходного хоста (норматив §12). Watchdog отдельно не переносится: его код — каталог
`watchdog/` того же репо, образ собирается локально compose'ом.
```bash
git clone <ORCHESTRATOR_GIT_URL> <путь-чекаута> # путь станет ORCH_DEPLOY_HOST_REPO_PATH
cd <путь-чекаута>
```
Конкретный канал дистрибуции (`<ORCHESTRATOR_GIT_URL>` — зеркало/архив/доступ к
Gitea поставщика) согласуйте с поставщиком платформы; опционально — `--branch <тег-среза>`.
**Проверка:**
```bash
git -C <путь-чекаута> log --oneline -1 # коммит виден
ls <путь-чекаута>/docker-compose.yml <путь-чекаута>/watchdog/Dockerfile \
<путь-чекаута>/.env.example <путь-чекаута>/.env.watchdog.example
```
Все файлы на месте — PASS.
---
## 4. Конфигурация
`.env` собирается **с нуля от канона `.env.example`** (100% ключей старта; полная карта
переменных и их семантика — `docs/operations/REPLICATION.md` §2). Дефолт каждого ключа =
значению исходного хоста, поэтому задаёте только то, что отличается у вас.
**4.1. Создать `.env` и выпустить webhook-секреты.**
```bash
cd <путь-чекаута>
cp .env.example .env
python3 scripts/gen_secrets.py # печатает свежие ORCH_PLANE_WEBHOOK_SECRET / ORCH_GITEA_WEBHOOK_SECRET
```
Вставьте оба напечатанных значения в `.env`. Секреты выпускаются **только заново**
боевые не копируются (§12).
**4.2. Обязательные ключи нового хоста** (заполняются в `.env` по ходу §5§8):
| Группа | Ключи | Откуда |
|--------|-------|--------|
| Plane | `ORCH_PLANE_API_URL`, `ORCH_PLANE_WEB_URL`, `ORCH_PLANE_WORKSPACE_SLUG`, `ORCH_PLANE_API_TOKEN` | §5 |
| Gitea | `ORCH_GITEA_URL`, `ORCH_GITEA_PUBLIC_URL`, `ORCH_GITEA_OWNER`, `ORCH_GITEA_TOKEN` | §6 |
| Webhook-секреты | `ORCH_PLANE_WEBHOOK_SECRET`, `ORCH_GITEA_WEBHOOK_SECRET` | 4.1 (`gen_secrets.py`) |
| Telegram | `ORCH_TELEGRAM_BOT_TOKEN`, `ORCH_TELEGRAM_CHAT_ID` | §8 |
| Реестр проектов | `ORCH_PROJECTS_JSON`**обязателен**: встроенный fallback несёт Plane-UUID исходного хоста | §10 |
| Хост-параметры | `ORCH_AGENT_HOME_DIR`, `ORCH_HOST_REPOS_DIR`, `ORCH_HOST_CLAUDE_DIR`, `ORCH_HOST_CLAUDE_JSON`, `ORCH_HOST_SSH_DIR`, `ORCH_HOST_CLAUDE_CODE_DIR`, `ORCH_HOST_NODE_BIN`, `ORCH_RUN_UID`, `ORCH_RUN_GID`, `ORCH_DOCKER_GID`, `ORCH_DEPLOY_HOST_REPO_PATH`, `ORCH_AGENT_GIT_NAME`, `ORCH_GIT_EMAIL_DOMAIN` | значения из §2§3 |
| Порты | `ORCH_DEPLOY_PROD_TARGET_PORT``WATCHDOG_METRICS_URL``ORCH_POST_DEPLOY_BASE_URL`; `ORCH_STAGING_PORT` ≠ прод-порт | §2.5 |
**4.3. Конфиг sidecar-watchdog — отдельный файл-носитель.** Sidecar-контейнер читает
**ТОЛЬКО `.env.watchdog`**; ключ `WATCHDOG_ENABLED` (и любой другой `WATCHDOG_*`),
положенный в `.env`, для sidecar **инертен**.
```bash
cp .env.watchdog.example .env.watchdog
# заполнить два ключа: WATCHDOG_TG_BOT_TOKEN / WATCHDOG_TG_CHAT_ID (бота создадим в §8)
```
**Проверка (резолв всей конфигурации):**
```bash
docker compose config >/dev/null && echo "compose config: PASS"
```
`PASS` без ошибок интерполяции — конфигурация согласована; ошибка — FAIL (ищите
незакрытую кавычку/невалидный JSON в `ORCH_PROJECTS_JSON`).
---
## 5. Подключение Plane
Инсталляция Plane — ваша; платформа подключается к ней API-токеном и webhook'ом.
**5.1. Workspace и проект.** Создайте workspace (его slug → `ORCH_PLANE_WEB_URL` /
`ORCH_PLANE_WORKSPACE_SLUG`) и проект под вашу разработку — через UI Plane.
```bash
# базовая доступность API из хоста оркестратора:
curl -fsS "$ORCH_PLANE_API_URL/api/v1/workspaces/<workspace-slug>/projects/" \
-H "X-API-Key: <plane-api-token>" | head -c 200
```
**Проверка:** HTTP 200 и JSON со списком проектов — PASS; 401/403 — токен (5.2) ещё не
выпущен или не имеет прав.
**5.2. API-токен.** Plane UI → Workspace Settings → API tokens → выпустить токен →
`ORCH_PLANE_API_TOKEN` в `.env`. Токен должен иметь право создавать проекты/статусы
(нужно для `onboard_project.py apply`, §10).
**5.3. Модель статусов — НЕ вручную.** Конвейеру нужны **22 канонических статуса** с
точными именами и группами; их создаёт `python3 scripts/onboard_project.py apply` (§10),
полная таблица — `docs/operations/ONBOARDING.md` §1 (golden source; здесь не дублируется).
Два имени фиксируем явно, потому что они **fail-closed** (без них ветка просто не
активируется, без ошибки): **`Confirm Deploy`** (человеческий гейт прод-деплоя) и
**`STOP`** (отмена задачи; обязан быть в группе `cancelled`).
```bash
# после §10 — проверить, что статусы созданы:
curl -fsS "$ORCH_PLANE_API_URL/api/v1/workspaces/<workspace-slug>/projects/<project-uuid>/states/" \
-H "X-API-Key: $ORCH_PLANE_API_TOKEN" | python3 -m json.tool | grep -c '"name"'
```
**Проверка:** счётчик имён = 22 (или больше, если в проекте остались дефолтные статусы
Plane) и среди них `Confirm Deploy` и `STOP` — PASS.
**5.4. Webhook + HMAC.** Приёмник — `POST https://<orchestrator-public-host>/webhook/plane`;
подпись — заголовок `X-Plane-Signature` (HMAC-SHA256, hex digest); секрет — значение
`ORCH_PLANE_WEBHOOK_SECRET` из 4.1. События: Issue, Issue Comment.
**Каверза Plane CE:** webhook **не экспонирован во внешнем `/api/v1`** — настраивается
одним из двух путей.
*Путь А — UI (если ваша сборка Plane его показывает):* Workspace Settings → Webhooks →
Add Webhook → URL + Secret (значение `ORCH_PLANE_WEBHOOK_SECRET`) → события Issue,
Issue Comment → Save.
*Путь Б — прямой SQL в Postgres инсталляции Plane:*
```bash
WORKSPACE_ID=$(docker exec -e PGPASSWORD=<plane-db-password> <plane-db-container> \
psql -U plane -d plane -t -A -c "SELECT id FROM workspaces WHERE slug='<workspace-slug>'")
WEBHOOK_ID=$(cat /proc/sys/kernel/random/uuid)
docker exec -e PGPASSWORD=<plane-db-password> <plane-db-container> psql -U plane -d plane -c "
INSERT INTO webhooks (id, created_at, updated_at, deleted_at, workspace_id, url, is_active,
secret_key, project, issue, module, cycle, issue_comment, is_internal, version)
VALUES ('${WEBHOOK_ID}', NOW(), NOW(), NULL, '${WORKSPACE_ID}',
'https://<orchestrator-public-host>/webhook/plane',
true, '<значение ORCH_PLANE_WEBHOOK_SECRET>', true, true, false, false, true, false, 'v1');
"
```
**Проверка:**
```bash
docker exec -e PGPASSWORD=<plane-db-password> <plane-db-container> psql -U plane -d plane -c \
"SELECT url, is_active FROM webhooks;"
```
Строка с вашим URL и `is_active = t` — PASS. Сквозная проверка доставки — §11 (smoke);
generic-образец команд и формат подписи — `docs/operations/SETUP_WEBHOOKS.md`.
---
## 6. Подключение Gitea
**6.1. Токен.** Gitea UI → Settings → Applications → Generate Token, scope: `repo`,
`admin:repo_hook``ORCH_GITEA_TOKEN` в `.env`.
```bash
curl -fsS -H "Authorization: token $ORCH_GITEA_TOKEN" "$ORCH_GITEA_URL/api/v1/user" | head -c 200
```
**Проверка:** HTTP 200 с JSON вашего пользователя — PASS; владелец репозиториев
(организация/пользователь) → `ORCH_GITEA_OWNER`, браузерный URL → `ORCH_GITEA_PUBLIC_URL`.
**6.2. Репо проекта.** Создаёт `onboard_project.py apply` (§10) — или вручную (пустой
репо + initial push). Чекаут обязан появиться в `$ORCH_HOST_REPOS_DIR/<repo>` (общий
каталог репозиториев из §2.2). Публичный ключ из §2.4 добавьте в Gitea
(Settings → SSH Keys), чтобы акторы могли пушить.
```bash
git -C "$ORCH_HOST_REPOS_DIR" clone <git-url-репо-проекта> <repo>
stat -c '%u:%g' "$ORCH_HOST_REPOS_DIR/<repo>" # владелец = ORCH_RUN_UID:ORCH_RUN_GID
```
**Проверка:** чекаут на месте, владелец совпадает — PASS.
**6.3. Per-repo webhook.** Создаёт `onboard_project.py apply` (§10). Параметры (если
вручную): URL `https://<orchestrator-public-host>/webhook/gitea`, content type `json`,
события **`push` / `pull_request` / `status`**, branch filter `*`, подпись —
`X-Gitea-Signature` (HMAC-SHA256). Секрет — **ОДИН глобальный `ORCH_GITEA_WEBHOOK_SECRET`
на ВСЕ репо** (приёмник валидирует только его; «свой секрет на репо» сломал бы HMAC
остальных).
```bash
curl -fsS -H "Authorization: token $ORCH_GITEA_TOKEN" \
"$ORCH_GITEA_URL/api/v1/repos/<owner>/<repo>/hooks" | python3 -m json.tool
```
**Проверка:** hook с вашим URL и тремя событиями существует, `active: true` — PASS.
**6.4. Норматив защиты `main` (ВАЖНО).** **Branch protection на `main` НЕ включать**
(никаких required-approvals / required-status-checks): merge-актор конвейера мержит PR
строго через Gitea PR-merge API (INV-4), и protection-правила дают 405/409-класс отказов →
ложные HOLD задач (ADR D10 ORCH-009). **pre-receive хуки платформа не вводит** и не
проверяет — защита `main` держится конвенцией (агенты не пушат `main`) + скоупом токенов.
```bash
curl -fsS -H "Authorization: token $ORCH_GITEA_TOKEN" \
"$ORCH_GITEA_URL/api/v1/repos/<owner>/<repo>/branch_protections" | python3 -m json.tool
```
**Проверка:** пустой список `[]` — PASS; есть правила на `main` — FAIL (удалите их,
симптом «PR не мержится / HOLD» — §13.7).
---
## 7. LLM (claude CLI)
Агенты конвейера — процессы claude CLI **внутри контейнера**, но дистрибутив, node и
аутентификация живут **на хосте** и пробрасываются маунтами (источники маунтов =
ключи `.env`).
**7.1. Дистрибутив claude-code и node.** Установите claude-code (npm-дистрибутив
Anthropic) и node на хост. Пути → `.env`:
```bash
which node # → ORCH_HOST_NODE_BIN
npm root -g # каталог глобальных модулей
ls "<npm-root>/@anthropic-ai/claude-code" # → ORCH_HOST_CLAUDE_CODE_DIR
```
**Проверка:** каталог дистрибутива существует и непуст — PASS. Внутри контейнера бинарь
доступен как `ORCH_CLAUDE_BIN` (дефолт менять не нужно).
**7.2. Аутентификация CLI.** Выполните первичный интерактивный логин claude CLI **на
хосте** под пользователем из §2.2 (по инструкции Anthropic к claude-code). Логин создаёт
каталог `~/.claude` и файл `~/.claude.json` — их пути задайте в `ORCH_HOST_CLAUDE_DIR` /
`ORCH_HOST_CLAUDE_JSON`.
```bash
claude --version # CLI работает
sudo -u "#<uid-из-2.2>" test -r <путь-~/.claude>/.credentials.json && echo "creds: PASS"
```
**Проверка:** версия печатается; `creds: PASS` — креды читаемы uid'ом контейнера
(иначе — `chown -R <uid>:<gid>` каталога, симптом §13.3).
**7.3. Модели агентов.** Резолв модели/эффорта — только из конфига (ORCH-41/74):
дефолты канона уже в `.env.example` (`ORCH_AGENT_MODEL_DEFAULT`,
`ORCH_AGENT_EFFORT_DEFAULT` и per-агент ключи рядом) — менять не обязательно.
```bash
grep -E '^ORCH_AGENT_(MODEL|EFFORT)_DEFAULT=' .env
```
**Проверка:** оба ключа присутствуют и непусты — PASS.
---
## 8. Telegram
Каналов **два и они независимы** (C-1 ORCH-100): бот live-трекера оркестратора и
**отдельный** бот sidecar-watchdog. Токен орка для watchdog переиспользовать
**ЗАПРЕЩЕНО** — упавший орк не сможет сообщить о себе своим же ботом.
**8.1. Бот трекера.** BotFather → `/newbot` → токен → `ORCH_TELEGRAM_BOT_TOKEN` в `.env`.
```bash
curl -fsS "https://api.telegram.org/bot<токен-трекера>/getMe"
# напишите боту любое сообщение (или добавьте его в чат), затем:
curl -fsS "https://api.telegram.org/bot<токен-трекера>/getUpdates" | python3 -m json.tool | grep -m1 '"id"'
```
**Проверка:** `getMe``"ok":true`; `id` чата из `getUpdates``ORCH_TELEGRAM_CHAT_ID`
PASS.
**8.2. Watchdog-бот (отдельный).** BotFather → `/newbot` ещё раз → токен и chat-id →
**`.env.watchdog`** (`WATCHDOG_TG_BOT_TOKEN` / `WATCHDOG_TG_CHAT_ID`). Помните о
файле-носителе: эти ключи работают только в `.env.watchdog` (§4.3).
```bash
curl -fsS "https://api.telegram.org/bot<токен-watchdog>/getMe"
grep -E '^WATCHDOG_TG_(BOT_TOKEN|CHAT_ID)=.+' .env.watchdog
```
**Проверка:** `getMe``"ok":true`; оба ключа в `.env.watchdog` непусты — PASS.
---
## 9. Запуск
**9.1. Базовый Lite-контур (дефолт): орк + watchdog.**
```bash
cd <путь-чекаута>
docker compose config --services # ровно: orchestrator, orchestrator-watchdog, orchestrator-staging
docker compose up -d --build
docker compose ps
```
**Проверка:** запущены **ровно два** контейнера — `orchestrator` и
`orchestrator-watchdog`; `orchestrator-staging` НЕ поднялся (он строго за
`profiles: [staging]`) — PASS. Поднялось что-то ещё/меньше — FAIL.
**9.2. Health-чек контрактов.**
```bash
curl -fsS http://127.0.0.1:8500/health
curl -fsS http://127.0.0.1:8500/queue | python3 -m json.tool | head -20
curl -fsS http://127.0.0.1:8500/metrics | python3 -m json.tool | head -10
```
**Проверка:** `/health` → HTTP 200, `"status":"ok"`; `/queue` → штатный JSON
(счётчики очереди); `/metrics` → JSON со `"schema_version": 1` — PASS. (Порт замените,
если меняли `ORCH_DEPLOY_PROD_TARGET_PORT`.)
**9.3. Вилка staging (опционально).** Базовому контуру «гонять СВОИ проекты» staging
**не нужен**. Он нужен ТОЛЬКО если вы регистрируете проект `orchestrator` (self-hosting
развитие самой платформы у себя): стадия `deploy-staging` требует песочницу на
`ORCH_STAGING_PORT` (изолированная БД `./data/staging`; guard ORCH-058: staging-порт ≠
прод-порт, fail-closed).
```bash
cp .env.staging.example .env.staging # заполнить по аналогии с .env
docker compose --profile staging up -d orchestrator-staging
curl -fsS http://127.0.0.1:8501/health
```
**Проверка (только для этой вилки):** `/health` staging → 200 — PASS.
---
## 10. Регистрация проекта заказчика
Onboarding-CLI создаёт Plane-проект с 22 статусами и лейблами (`autoApprove` /
`autoDeploy` / `Bug`), Gitea-репо с webhook'ом, скелет репо (kit) и печатает merged-реестр.
Полный runbook — `docs/operations/ONBOARDING.md`; Lite-последовательность:
```bash
cd <путь-чекаута>
python3 scripts/onboard_project.py plan \
--name "<имя проекта>" --description "<зачем проект>" \
--repo <repo> --prefix <PREFIX> \
--stack "<стек>" --test-cmd "<команда тестов>" \
--prod-port <порт-прода-проекта> --staging-port <порт-staging-проекта> \
--webhook-url https://<orchestrator-public-host>/webhook/gitea
# план устроил → тот же вызов с apply; затем read-only контроль:
python3 scripts/onboard_project.py verify <те же аргументы>
```
**Проверка:** `apply` завершился без ошибок (exit 0; `2` = остались 🖐 ручные шаги — выполните
их по отчёту), `verify` зелёный — PASS.
Дальше реестр и рестарт:
```bash
# 1) строку ORCH_PROJECTS_JSON=[...] из отчёта apply вставить в .env (заменить целиком);
# 2) дождаться тихого окна и управляемо перезапустить орк:
curl -fsS http://127.0.0.1:8500/queue | python3 -m json.tool | head -20 # нет running-job
docker compose up -d --force-recreate orchestrator
# 3) убедиться, что инстанс жив и реестр подхвачен:
curl -fsS http://127.0.0.1:8500/health
curl -fsS http://127.0.0.1:8500/queue | python3 -m json.tool | head -20
```
**Проверка:** `/health` → 200 после рестарта; в Plane создан проект со статусами
(см. §5.3), в Gitea — репо с webhook (§6.3) — PASS.
---
## 11. Smoke: «конвейер доехал»
Процедура — чек-лист `docs/operations/REPLICATION.md` §4 (шаги 05; шаг 6 «до `done`» —
опционально), без форка; каждый шаг несёт явный PASS/FAIL. Lite-предусловия: §2§10 этого
дока выполнены, проект заказчика зарегистрирован (§10).
Минимальный сигнал «конвейер доехал» (шаги 45 чек-листа): создайте issue в Plane-проекте
и переведите в статус **To Analyse**, затем:
```bash
# задача появилась и analyst-job в очереди:
curl -fsS http://127.0.0.1:8500/queue | python3 -m json.tool | head -40
# по завершении стадии analysis — артефакты 0104 в ветке задачи:
git -C "$ORCH_HOST_REPOS_DIR/<repo>" fetch origin
git -C "$ORCH_HOST_REPOS_DIR/<repo>" ls-tree --name-only origin/<ветка-задачи> "docs/work-items/<id-задачи>/"
```
**Проверка:** в `/queue` виден job задачи; `ls-tree` показывает `01-brd.md`
`04-test-plan.yaml` — PASS.
**Итоговый вердикт:** все шаги чек-листа PASS ⇒ **тираж PASS**; любой шаг FAIL ⇒ тираж
FAIL — соберите `docker logs orchestrator --tail 100` и снапшот `GET /queue`, разбор —
§13.
---
## 12. Stateless-проверка
**Нормативно: данные/задачи/секреты боевого (исходного) хоста НЕ переносятся** (зеркало
`docs/operations/REPLICATION.md` §5). БД создаётся **пустой** при первом старте; `.env` /
`.env.staging` / `.env.watchdog` собраны с нуля (§4); секреты — только свежевыпущенные
(`gen_secrets.py` + чек-лист внешних токенов `docs/operations/REPLICATION.md` §3).
Проверка чистоты развёрнутого инстанса (выполнить ДО первой своей задачи):
```bash
ls -la <путь-чекаута>/data/ # БД появилась только после первого старта
curl -fsS http://127.0.0.1:8500/queue | python3 -m json.tool # счётчики jobs нулевые
```
**Проверка:** в `/queue` нулевые счётчики и НИ ОДНОЙ задачи чужих проектов (никаких
`ORCH-*`/`ET-*` исходного хоста) — PASS. Любая чужая задача/перенесённый файл БД — FAIL:
инстанс собран не stateless, пересоберите `data/` с нуля.
---
## 13. Траблшутинг первичной настройки
Формат: симптом → команда диагностики → лечение.
**13.1. Webhook отвечает 401 / HMAC mismatch.**
```bash
docker logs orchestrator --tail 50 2>&1 | grep -i "webhook\|signature\|401"
```
Лечение: секрет в `.env` (`ORCH_PLANE_WEBHOOK_SECRET` / `ORCH_GITEA_WEBHOOK_SECRET`)
обязан **байт-в-байт** совпадать с секретом в настройке webhook'а (Plane §5.4 / Gitea
§6.3); после правки `.env` — управляемый рестарт (§10). Формат подписи —
`docs/operations/SETUP_WEBHOOKS.md`.
**13.2. Задача в Plane создана, но в оркестраторе не появилась.**
```bash
curl -fsS http://127.0.0.1:8500/queue | python3 -m json.tool | head -30 # есть ли job
docker logs orchestrator --tail 50 2>&1 | grep -i "ignored\|unknown project"
grep ORCH_PROJECTS_JSON .env # uuid вашего проекта в реестре?
```
Лечение: (а) проект отсутствует/с чужим UUID в `ORCH_PROJECTS_JSON` → поправить реестр
(§10) + рестарт; (б) webhook не доставлен → Plane: `SELECT url, is_active FROM webhooks;`
(§5.4), Gitea: Recent Deliveries в настройках hook'а; (в) подпись → §13.1.
**13.3. claude CLI не найден / не аутентифицирован (агент падает на старте).**
```bash
docker exec orchestrator /usr/bin/claude --version
sudo -u "#<uid-из-2.2>" test -r <путь-~/.claude>/.credentials.json && echo "creds: PASS"
```
Лечение: маунты указывают на фактические пути хоста (`ORCH_HOST_CLAUDE_CODE_DIR`,
`ORCH_HOST_NODE_BIN`, `ORCH_HOST_CLAUDE_DIR`, `ORCH_HOST_CLAUDE_JSON` в `.env`); креды
читаемы uid'ом из §2.2 (`chown -R <uid>:<gid>`); при невалидной сессии — повторный логин
на хосте (§7.2) + `docker compose up -d --force-recreate orchestrator`.
**13.4. `docker.sock: permission denied` в логах орка/watchdog.**
```bash
getent group docker # фактический gid
grep ORCH_DOCKER_GID .env # gid в конфиге
```
Лечение: значения обязаны совпадать → выставить `ORCH_DOCKER_GID` = фактическому gid и
`docker compose up -d --force-recreate`.
**13.5. `Permission denied` при создании worktree (права `/repos`, ORCH-040/057).**
```bash
stat -c '%u:%g' "$ORCH_HOST_REPOS_DIR" "$ORCH_HOST_REPOS_DIR/<repo>"
grep -E '^ORCH_RUN_(UID|GID)=' .env
```
Лечение: владелец каталога репо обязан совпадать с `ORCH_RUN_UID:ORCH_RUN_GID`
(§2.2) → `chown -R <uid>:<gid> "$ORCH_HOST_REPOS_DIR"` (включая legacy root-owned файлы)
и пересоздать контейнер.
**13.6. Telegram молчит (нет карточек/алертов).**
```bash
curl -fsS "https://api.telegram.org/bot<токен-трекера>/getMe"
curl -fsS "https://api.telegram.org/bot<токен-watchdog>/getMe"
grep -E '^ORCH_TELEGRAM_(BOT_TOKEN|CHAT_ID)=.+' .env
grep -E '^WATCHDOG_TG_(BOT_TOKEN|CHAT_ID)=.+' .env.watchdog
```
Лечение: оба бота отвечают `"ok":true`; chat-id корректен (бот добавлен в чат / получил
сообщение); ключи watchdog-бота лежат именно в `.env.watchdog``.env` они инертны,
§4.3); пустой токен = режим «логи без отправки» (fail-safe, не ошибка).
**13.7. PR задачи не мержится / задача в HOLD.** Первая проверка — **не включена ли
branch protection на `main`** (§6.4):
```bash
curl -fsS -H "Authorization: token $ORCH_GITEA_TOKEN" \
"$ORCH_GITEA_URL/api/v1/repos/<owner>/<repo>/branch_protections" | python3 -m json.tool
```
Лечение: непустой список правил на `main` → удалить (норматив §6.4); merge выполняет
PR-merge API оркестратора, ручной merge не требуется.
---
*Golden source Lite-тиража (ORCH-102, ADR-001). **Норматив сопровождения (NFR-5):**
меняешь шаги тиража (env-ключи, compose-сервисы, маршрут онбординга, smoke) → обнови
этот док В ТОМ ЖЕ PR (правило агентов №2). Полноту и гигиену дока держит структурный
анти-дрейф тест `tests/test_lite_setup_doc.py`; кирпичи-каноны: REPLICATION.md (карта
env §2, секреты §3, smoke §4), ONBOARDING.md (статусы §1, онбординг), SETUP_WEBHOOKS.md
(формат вебхуков), `.env.example` / `.env.watchdog.example` (канон ключей).*

View File

@@ -179,7 +179,7 @@ watchdog'а: **watchdog сигналит, pruner убирает**.
| `ORCH_RUN_UID` / `ORCH_RUN_GID` | ORCH-101: uid:gid контейнера (`user:`) + `ARG APP_UID/APP_GID` (дефолт `1000:1000`, ORCH-040) |
| `ORCH_DOCKER_GID` | ORCH-101: gid docker-группы хоста для `group_add` (дефолт `999`; «МИНА 1» ORCH-040 — не удалять) |
**Секреты — только в `.env` / `.env.staging` на хосте, в гит НЕ коммитятся.** Канон — `.env.example`, `.env.staging.example`. Выпуск нового комплекта секретов для нового хоста — `scripts/gen_secrets.py` (боевые секреты не копируются). **Тираж платформы на новую инфру** (карта переменных, секреты, smoke-процедура, границы Lite/Bundled) — `docs/operations/REPLICATION.md` (ORCH-101). Когерентность портов при смене прод-порта: `ORCH_DEPLOY_PROD_TARGET_PORT``WATCHDOG_METRICS_URL``ORCH_POST_DEPLOY_BASE_URL`.
**Секреты — только в `.env` / `.env.staging` / `.env.watchdog` на хосте, в гит НЕ коммитятся.** Канон — `.env.example`, `.env.staging.example`, `.env.watchdog.example` (ORCH-102: sidecar-watchdog читает ТОЛЬКО `.env.watchdog`; `WATCHDOG_*` в `.env` для него инертен). Выпуск нового комплекта секретов для нового хоста — `scripts/gen_secrets.py` (боевые секреты не копируются). **Тираж платформы на новую инфру** (карта переменных, секреты, smoke-процедура, границы Lite/Bundled) — `docs/operations/REPLICATION.md` (ORCH-101); сквозная инструкция Lite-тиража для внешнего оператора («голый хост → конвейер», орк+watchdog) — `docs/deployment/LITE_SETUP.md` (ORCH-102). Когерентность портов при смене прод-порта: `ORCH_DEPLOY_PROD_TARGET_PORT``WATCHDOG_METRICS_URL``ORCH_POST_DEPLOY_BASE_URL`.
## Реестр проектов (`src/projects.py`, ORCH-6)
Связывает Plane project id → gitea repo + work-item prefix. Источник: `ORCH_PROJECTS_JSON`, fallback — встроенный дефолт. Прод видит: `enduro-trails` (ET), `orchestrator` (ORCH). Staging видит ТОЛЬКО `orchestrator-sandbox` (SANDBOX) — изоляция.

View File

@@ -12,7 +12,7 @@
| Слой | Что это | Статус |
|------|---------|--------|
| **10-common** (этот док) | фундамент: все хост-значения параметризованы (env/конфиг), секреты выпускаются заново, smoke-процедура с PASS/FAIL | ✅ ORCH-101 |
| **Type A — Lite** | инструкция «поставь Plane+Gitea сам, подключи оркестратор» поверх 10-common | отдельная задача эпика |
| **Type A — Lite** | инструкция «поставь Plane+Gitea сам, подключи оркестратор» поверх 10-common | ✅ ORCH-102 — [`docs/deployment/LITE_SETUP.md`](../deployment/LITE_SETUP.md) |
| **Type B — Bundled** | комплект «всё в одном» (Plane+Gitea+оркестратор) поверх 10-common | отдельная задача эпика |
Этот док НЕ описывает установку Plane/Gitea — только параметризацию, секреты и

View File

@@ -0,0 +1,428 @@
"""ORCH-102 (FR-6 / AC-1, AC-2, AC-3, AC-6): анти-дрейф контур Lite-тиража.
Структурные проверки golden source `docs/deployment/LITE_SETUP.md` (ADR-001 D8,
образец — `tests/test_replication_smoke.py`): док существует и несёт 13
нормативных разделов D2 в порядке маршрута оператора; обязательные кирпичи
(TC-02); key-sync `.env.watchdog.example` ⇄ блок `WATCHDOG_*` `.env.example`
(TC-02b, D5); каждый упомянутый в доке env-ключ существует в `.env.example`
(TC-03, анти-опечатка); compose-подмножество — ровно орк+watchdog по дефолту,
staging строго за профилем, никаких Plane/Gitea-сервисов (TC-04, D4);
stateless-норматив и секрет-гигиена fenced-блоков (TC-05, FORBIDDEN —
импорт из `tests/test_no_host_hardcodes.py`, не копия); канон не форкается —
статусы/env/smoke ссылками, fail-closed имена явно (TC-06, D3); инварианты
Gitea-раздела (TC-07); перекрёстность REPLICATION→LITE_SETUP + CHANGELOG
(TC-08). Детерминировано: без сети/LLM/subprocess (NFR/TR-7 — ассерты только
на стабильное: заголовки, подстроки-кирпичи, парсинг ключей, yaml.safe_load).
"""
import re
from pathlib import Path
import yaml
# Один источник истины запрещённых боевых литералов (ADR-001 D8 / ORCH-101 AC-7).
from tests.test_no_host_hardcodes import FORBIDDEN
REPO_ROOT = Path(__file__).resolve().parents[1]
LITE_SETUP = REPO_ROOT / "docs/deployment/LITE_SETUP.md"
REPLICATION = REPO_ROOT / "docs/operations/REPLICATION.md"
CHANGELOG = REPO_ROOT / "CHANGELOG.md"
ENV_EXAMPLE = REPO_ROOT / ".env.example"
ENV_WATCHDOG_EXAMPLE = REPO_ROOT / ".env.watchdog.example"
COMPOSE = REPO_ROOT / "docker-compose.yml"
# Нормативная структура D2: фиксированная нумерация, порядок = маршрут оператора.
SECTIONS: tuple[str, ...] = (
"## 1. Рамка Lite",
"## 2. Предусловия хоста",
"## 3. Перенос кода",
"## 4. Конфигурация",
"## 5. Подключение Plane",
"## 6. Подключение Gitea",
"## 7. LLM (claude CLI)",
"## 8. Telegram",
"## 9. Запуск",
"## 10. Регистрация проекта",
"## 11. Smoke",
"## 12. Stateless-проверка",
"## 13. Траблшутинг",
)
# Обязательные кирпичи FR-6.1 (подстроки; TC-02).
BRICKS: tuple[str, ...] = (
"gen_secrets.py",
"onboard_project.py",
"docker compose",
"/health",
"/queue",
"/metrics",
"ORCH_PROJECTS_JSON",
"ORCH_PLANE_WEBHOOK_SECRET",
"ORCH_GITEA_WEBHOOK_SECRET",
"X-Plane-Signature",
"X-Gitea-Signature",
"getent group docker",
"Confirm Deploy",
"STOP",
"ORCH_TELEGRAM_BOT_TOKEN", # канал трекера орка
"WATCHDOG_TG_BOT_TOKEN", # независимый канал sidecar-watchdog (C-1 ORCH-100)
"PASS",
"FAIL",
"Проверка",
)
# Обязательный набор ключей нового хоста, упоминаемый в доке ЯВНО (TC-03 / FR-1.4).
MANDATORY_NEW_HOST_KEYS: tuple[str, ...] = (
"ORCH_PROJECTS_JSON",
"ORCH_PLANE_WEBHOOK_SECRET",
"ORCH_GITEA_WEBHOOK_SECRET",
"ORCH_PLANE_API_TOKEN",
"ORCH_GITEA_TOKEN",
"ORCH_TELEGRAM_BOT_TOKEN",
"ORCH_TELEGRAM_CHAT_ID",
"WATCHDOG_TG_BOT_TOKEN",
"WATCHDOG_TG_CHAT_ID",
)
# env-токен в доке: полное имя ключа, не заканчивающееся на «_» (glob-упоминания
# вида ORCH_FOO_* в доке запрещены формой D2 — пишутся полные имена).
_ENV_TOKEN_RE = re.compile(r"\b(?:ORCH|WATCHDOG)_[A-Z0-9_]*[A-Z0-9]\b")
# Секретоподобные значения в копируемых блоках (TC-05, эвристика D8):
# hex-run >= 32 симв. (token_hex) либо чистый alnum-run >= 40 симв. (токены ботов
# и т.п.); плейсхолдеры <...>/$ENV_VAR под эти классы не попадают.
_SECRET_HEX_RE = re.compile(r"\b[0-9a-fA-F]{32,}\b")
_SECRET_ALNUM_RE = re.compile(r"\b[A-Za-z0-9]{40,}\b")
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _doc_text() -> str:
assert LITE_SETUP.is_file(), "docs/deployment/LITE_SETUP.md отсутствует (AC-1)"
return LITE_SETUP.read_text(encoding="utf-8")
def _env_keys(path: Path) -> set[str]:
"""Множество ключей `KEY=` файла env-канона (комментарии/пустые — мимо)."""
keys: set[str] = set()
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
keys.add(line.split("=", 1)[0].strip())
return keys
def _env_values(path: Path) -> dict[str, str]:
"""Словарь `KEY -> value` файла env-канона."""
values: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
values[k.strip()] = v.strip()
return values
def _fenced_blocks(text: str) -> list[str]:
"""Содержимое fenced code blocks — единственная копируемая зона дока (D2/D8)."""
return re.findall(r"```[^\n]*\n(.*?)```", text, flags=re.DOTALL)
def _section_bodies() -> dict[str, str]:
"""Тело каждого нормативного раздела (от заголовка до следующего `## `)."""
text = _doc_text()
bodies: dict[str, str] = {}
for i, header in enumerate(SECTIONS):
start = text.find(header)
assert start != -1, f"раздел {header!r} отсутствует"
end = text.find(SECTIONS[i + 1]) if i + 1 < len(SECTIONS) else len(text)
bodies[header] = text[start:end]
return bodies
def _compose_services() -> dict:
return yaml.safe_load(COMPOSE.read_text(encoding="utf-8"))["services"]
# ---------------------------------------------------------------------------
# TC-01: док существует; 13 нормативных разделов D2 — в заданном порядке (AC-1).
# ---------------------------------------------------------------------------
def test_doc_exists_with_all_13_sections_in_order():
text = _doc_text()
positions: list[int] = []
for header in SECTIONS:
idx = text.find(header)
assert idx != -1, f"нормативный раздел {header!r} отсутствует (D2/FR-1)"
positions.append(idx)
assert positions == sorted(positions), (
"разделы LITE_SETUP.md идут не в порядке маршрута оператора (D2)"
)
# ---------------------------------------------------------------------------
# TC-02: обязательные кирпичи + форма «команда + проверка» (AC-1 / NFR-6).
# ---------------------------------------------------------------------------
def test_doc_carries_all_mandatory_bricks():
text = _doc_text()
missing = [b for b in BRICKS if b not in text]
assert not missing, f"обязательные кирпичи отсутствуют в LITE_SETUP.md (FR-6.1): {missing}"
def test_every_normative_section_carries_commands():
"""Каждый исполняемый раздел (§2§13) несёт минимум одну fenced-команду;
§1 (рамка/границы) — единственный раздел без команд по построению."""
bodies = _section_bodies()
for header in SECTIONS[1:]:
assert "```" in bodies[header], f"{header}: нет ни одной fenced-команды (NFR-6)"
def test_doc_carries_explicit_check_markers():
"""Маркеры явной проверки результата: не меньше одного на исполняемый раздел."""
text = _doc_text()
assert text.count("Проверка") >= 12, (
"маркеров «Проверка» меньше, чем исполняемых разделов (форма D2: "
"каждый шаг = команда + явная проверка)"
)
assert "PASS" in text and "FAIL" in text
# ---------------------------------------------------------------------------
# TC-02b: key-sync .env.watchdog.example ⇄ блок WATCHDOG_* .env.example (D5).
# ---------------------------------------------------------------------------
def test_watchdog_example_exists():
assert ENV_WATCHDOG_EXAMPLE.is_file(), ".env.watchdog.example отсутствует (ADR-001 D5)"
def test_watchdog_example_keys_sync_with_env_example_block():
"""Равенство МНОЖЕСТВ ключей (не строк): появление нового WATCHDOG_*-ключа в
каноне без обновления example (и наоборот) рвёт CI (D5/TR-8)."""
watchdog_keys = _env_keys(ENV_WATCHDOG_EXAMPLE)
canon_block = {k for k in _env_keys(ENV_EXAMPLE) if k.startswith("WATCHDOG_")}
assert canon_block, "блок WATCHDOG_* в .env.example пуст — канон сломан"
assert watchdog_keys == canon_block, (
f"key-set .env.watchdog.example разъехался с каноном .env.example: "
f"лишние={sorted(watchdog_keys - canon_block)}, "
f"недостающие={sorted(canon_block - watchdog_keys)}"
)
stray = {k for k in watchdog_keys if not k.startswith("WATCHDOG_")}
assert not stray, f"посторонние (не WATCHDOG_*) ключи в .env.watchdog.example: {sorted(stray)}"
def test_watchdog_example_secrets_are_placeholders_only():
"""Зеркало test_env_example_secrets_are_placeholders_only (ORCH-101):
токены sidecar-бота в гите — только пустые плейсхолдеры (NFR-3)."""
values = _env_values(ENV_WATCHDOG_EXAMPLE)
for key in ("WATCHDOG_TG_BOT_TOKEN", "WATCHDOG_TG_CHAT_ID"):
assert values.get(key, "") == "", (
f"{key} в .env.watchdog.example обязан быть пустым плейсхолдером, "
f"получено {values.get(key)!r}"
)
# ---------------------------------------------------------------------------
# TC-03: согласованность env-канона (AC-1, AC-6 / FR-6.2).
# ---------------------------------------------------------------------------
def test_every_env_token_in_doc_exists_in_env_example():
"""Каждый упомянутый в доке ключ ORCH_*/WATCHDOG_* существует в `.env.example`
(канон 100% ключей старта, ORCH-101) — анти-опечатка/анти-дрейф."""
canon = _env_keys(ENV_EXAMPLE)
mentioned = set(_ENV_TOKEN_RE.findall(_doc_text()))
assert mentioned, "в LITE_SETUP.md не упомянут ни один env-ключ — док не полон"
unknown = sorted(mentioned - canon)
assert not unknown, (
f"ключи из LITE_SETUP.md отсутствуют в .env.example (опечатка или дрейф "
f"канона, FR-6.2): {unknown}"
)
def test_mandatory_new_host_keys_are_explicit():
text = _doc_text()
missing = [k for k in MANDATORY_NEW_HOST_KEYS if k not in text]
assert not missing, f"обязательные ключи нового хоста не упомянуты явно (FR-1.4): {missing}"
# ---------------------------------------------------------------------------
# TC-04: compose-подмножество (AC-2 / D4) — yaml.safe_load, без subprocess.
# ---------------------------------------------------------------------------
def test_compose_services_are_exactly_the_lite_set():
services = _compose_services()
assert set(services) == {"orchestrator", "orchestrator-watchdog", "orchestrator-staging"}, (
"множество сервисов docker-compose.yml разъехалось с Lite-подмножеством (AC-2)"
)
def test_compose_staging_is_strictly_behind_profile():
services = _compose_services()
assert services["orchestrator-staging"].get("profiles") == ["staging"], (
"orchestrator-staging обязан быть строго за profiles: [staging] (AC-2)"
)
default_up = {name for name, svc in services.items() if not svc.get("profiles")}
assert default_up == {"orchestrator", "orchestrator-watchdog"}, (
f"дефолтный `docker compose up -d` поднимает {sorted(default_up)}, "
"а обязан — ровно орк+watchdog (AC-2)"
)
def test_compose_has_no_plane_or_gitea_services():
"""Анти-появление молча: ни в имени сервиса, ни в image:/container_name:
нет подстрок plane/gitea (D4)."""
services = _compose_services()
for name, svc in services.items():
blob = " ".join(
[name, str(svc.get("image", "")), str(svc.get("container_name", ""))]
).lower()
for needle in ("plane", "gitea"):
assert needle not in blob, (
f"сервис {name}: в compose появился {needle}-компонент — "
"Lite-подмножество сломано (AC-2)"
)
def test_doc_documents_default_up_composition():
"""LITE_SETUP.md фиксирует состав дефолтного запуска и вилку staging (D6)."""
text = _doc_text()
assert "orchestrator-watchdog" in text
assert "orchestrator-staging" in text
assert "--profile staging" in text # staging поднимается только явным профилем
# ---------------------------------------------------------------------------
# TC-05: stateless-норматив + секрет-гигиена fenced-блоков (AC-3 / FR-3, NFR-3).
# ---------------------------------------------------------------------------
def test_doc_has_stateless_normative_line():
low = _doc_text().lower()
assert "не перенос" in low, (
"нормативная stateless-строка («данные/задачи/секреты боевого хоста "
"НЕ переносятся») отсутствует (AC-3)"
)
def test_doc_prescribes_clean_db_and_fresh_secrets():
text = _doc_text()
assert "gen_secrets.py" in text # секреты — только выпуск НОВЫХ
bodies = _section_bodies()
stateless = bodies["## 12. Stateless-проверка"]
assert "/queue" in stateless, (
"§12 обязан нести проверку чистоты инстанса через GET /queue (FR-1 п.12)"
)
def test_fenced_blocks_carry_no_forbidden_literals():
"""Копируемые блоки generic: боевые литералы (центральный список FORBIDDEN
из tests/test_no_host_hardcodes.py — один источник истины) запрещены."""
blocks = _fenced_blocks(_doc_text())
assert blocks, "в LITE_SETUP.md нет ни одного fenced-блока — форма D2 нарушена"
offenders = [
f"блок #{i}: {literal!r}"
for i, block in enumerate(blocks)
for literal in FORBIDDEN
if literal in block
]
assert not offenders, (
"боевые литералы в копируемых код-блоках LITE_SETUP.md (NFR-3):\n"
+ "\n".join(offenders)
)
def test_fenced_blocks_carry_no_secret_like_values():
"""Эвристика секретоподобных значений (D8): hex >= 32 / чистый alnum >= 40."""
offenders = []
for i, block in enumerate(_fenced_blocks(_doc_text())):
for rx in (_SECRET_HEX_RE, _SECRET_ALNUM_RE):
m = rx.search(block)
if m is not None:
offenders.append(f"блок #{i}: {m.group(0)[:16]}")
assert not offenders, (
"секретоподобные значения в копируемых код-блоках (только плейсхолдеры "
"<...>/$ENV_VAR, NFR-3):\n" + "\n".join(offenders)
)
def test_secret_heuristic_is_not_evergreen():
"""Негативный самочек (паттерн ORCH-101 TC-02): эвристика реально ловит
подсаженный секрет — тест не может молча стать вечнозелёным."""
planted_hex = "export TOKEN=" + "a1b2c3d4" * 8 # 64 hex
planted_alnum = "bot" + "A9" * 25 # 50+ alnum
assert _SECRET_HEX_RE.search(planted_hex) is not None
assert _SECRET_ALNUM_RE.search(planted_alnum) is not None
assert _SECRET_HEX_RE.search("curl -fsS http://127.0.0.1:8500/health") is None
assert _SECRET_ALNUM_RE.search("<ORCHESTRATOR_GIT_URL> $ORCH_PLANE_API_TOKEN") is None
# ---------------------------------------------------------------------------
# TC-06: канон не форкается (AC-6 / FR-4) — ссылки на golden source.
# ---------------------------------------------------------------------------
def test_plane_canon_is_linked_not_forked():
"""Статусы — ссылкой на ONBOARDING.md (создаёт onboard_project.py apply);
в доке явно — только fail-closed имена Confirm Deploy / STOP."""
bodies = _section_bodies()
plane = bodies["## 5. Подключение Plane"]
assert "ONBOARDING.md" in plane, "раздел Plane не ссылается на golden source статусов"
assert "Confirm Deploy" in plane and "STOP" in plane, (
"fail-closed имена статусов не упомянуты явно (FR-4)"
)
def test_status_count_claim_matches_plane_sync():
"""Сверка импортом (не строковой копией): заявление дока «22 статуса»
держится фактическим маппингом src/plane_sync.py (нулевой дрейф, AC-6)."""
from src.plane_sync import _PLANE_NAME_TO_KEY
assert len(_PLANE_NAME_TO_KEY) == 22, (
f"в plane_sync {_PLANE_NAME_TO_KEY and len(_PLANE_NAME_TO_KEY)} статусов — "
"обнови число и раздел §5 LITE_SETUP.md (и ONBOARDING.md §1)"
)
assert "Confirm Deploy" in _PLANE_NAME_TO_KEY
assert "STOP" in _PLANE_NAME_TO_KEY
assert "22" in _doc_text(), "число статусов в LITE_SETUP.md разъехалось с plane_sync"
def test_env_map_and_smoke_are_linked_to_replication():
bodies = _section_bodies()
assert "REPLICATION.md" in bodies["## 4. Конфигурация"], (
"карта env обязана даваться ссылкой на REPLICATION.md §2 (FR-4)"
)
assert "REPLICATION.md" in bodies["## 11. Smoke"], (
"smoke обязан строиться на REPLICATION.md §4 ссылкой, без форка (FR-5)"
)
# ---------------------------------------------------------------------------
# TC-07: раздел Gitea соответствует инвариантам платформы (AC-7 / D3).
# ---------------------------------------------------------------------------
def test_gitea_section_fixes_platform_invariants():
bodies = _section_bodies()
gitea = bodies["## 6. Подключение Gitea"]
for event in ("push", "pull_request", "status"):
assert event in gitea, f"событие webhook {event!r} не зафиксировано в §6"
assert "ОДИН глобальный" in gitea or "один глобальный" in gitea.lower(), (
"§6 обязан фиксировать «ОДИН глобальный webhook-секрет на все репо»"
)
def test_gitea_section_forbids_branch_protection():
"""Исход А-1 (D3): branch protection на main НЕ включать (ADR D10 ORCH-009,
ломает PR-merge API merge-актора); pre-receive хуки не вводятся."""
bodies = _section_bodies()
gitea = bodies["## 6. Подключение Gitea"]
assert "branch protection" in gitea.lower(), "§6 не несёт норматив про branch protection"
assert "НЕ включа" in gitea, "§6 обязан нормативно запрещать branch protection на main"
assert "pre-receive" in gitea.lower(), "§6 обязан фиксировать: pre-receive хуки не вводятся"
# ---------------------------------------------------------------------------
# TC-08: перекрёстная документация (AC-5 / FR-7).
# ---------------------------------------------------------------------------
def test_replication_boundaries_reference_lite_setup():
text = REPLICATION.read_text(encoding="utf-8")
assert "LITE_SETUP.md" in text, (
"REPLICATION.md §1 обязан ссылаться на LITE_SETUP.md (Type A — Lite реализован)"
)
assert "ORCH-102" in text, "строка Type A — Lite в REPLICATION.md §1 не отмечена ✅ ORCH-102"
def test_changelog_has_orch_102_entry():
assert "ORCH-102" in CHANGELOG.read_text(encoding="utf-8")