diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ba7915e..aaeb882 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -210,9 +210,12 @@ sidecar читает только `.env.watchdog`; C-1 ORCH-100 — отдель (docs+tests). Подробнее: [adr-0037](adr/adr-0037-lite-replication-canon.md), детально — `docs/work-items/ORCH-102/06-adr/ADR-001-lite-setup-doc-canon.md`. -**Type B — Bundled (ORCH-103 — design).** Закрывает эпик ORCH-10: весь стек одним комплектом +**Type B — Bundled (ORCH-103).** Закрывает эпик ORCH-10: весь стек одним комплектом (орк + watchdog + Gitea + Plane CE ≈13–14 контейнеров) для заказчика без собственной -инфраструктуры. Новый top-level каталог **`deploy/`** (исполняемые дистрибутивы; дополняет +инфраструктуры. Состав Plane — зеркало официального selfhost-référence v0.23.1 +(upstream-имена сервисов web/space/admin/api/worker/beat-worker/migrator/live + +plane-db/plane-redis/plane-mq/plane-minio/proxy); Gitea — `gitea/gitea:1.22.6` +(не rootless, ssh выключен). Новый top-level каталог **`deploy/`** (исполняемые дистрибутивы; дополняет `docs/deployment/` — инструкции): `deploy/bundled/docker-compose.yml` — один самодостаточный compose с `name: orchestrator-bundle` (узнаваемый префикс томов/контейнеров; `container_name` не пиннится — нет коллизий с корневым compose на одном хосте), пиннинг сторонних образов diff --git a/docs/deployment/BUNDLED_SETUP.md b/docs/deployment/BUNDLED_SETUP.md new file mode 100644 index 0000000..f0163e0 --- /dev/null +++ b/docs/deployment/BUNDLED_SETUP.md @@ -0,0 +1,436 @@ +# BUNDLED_SETUP — Bundled-тираж: весь стек одним комплектом (ORCH-103) + +> **Golden source Bundled-тиража (Type B эпика ORCH-10).** Маршрут «чистый хост → +> работающий конвейер» для заказчика **без собственной инфраструктуры**: один +> compose-комплект (`deploy/bundled/docker-compose.yml`) поднимает оркестратор + +> watchdog + Gitea + Plane CE, один запуск `scripts/bootstrap_bundle.py apply` +> доводит стек до рабочего состояния. Каждый шаг — исполняемая команда + явная +> проверка результата (**Проверка:** / PASS / FAIL). Хост-специфика — только +> плейсхолдеры `<...>` и `$ENV_VAR`. Тираж **stateless**: данные/задачи/секреты +> боевого (исходного) хоста **НЕ переносятся** ни на одном шаге (§12). +> Границы слоёв тиража (10-common vs Lite vs Bundled) — `docs/operations/REPLICATION.md` §1; +> канон Lite (своя инфраструктура Plane/Gitea) — `docs/deployment/LITE_SETUP.md`. + +--- + +## 1. Рамка Bundled + +**Что входит в комплект** (compose-проект `orchestrator-bundle`, одна bridge-сеть): +- `orchestrator` (конвейер, образ собирается из этого чекаута) и + `orchestrator-watchdog` (независимый sidecar-мониторинг); +- **Gitea** (git-хостинг, пиннованный официальный образ); +- **Plane CE — ≈ 14 контейнеров** (зеркало официального selfhost-комплекта: + web/space/admin/api/worker/beat-worker/migrator/live + postgres/redis/ + rabbitmq/minio/proxy) — это **ресурсоёмко**, см. §2. + +**Что НЕ входит** (внешние предусловия заказчика): +- **Claude CLI / LLM-доступ** — дистрибутив claude-code, node и аутентификация + живут на хосте и пробрасываются маунтами (§8); без них стек поднимется, но + конвейер не поедет; +- **Telegram-боты** — опциональны (§9): пусто = деградация только нотификаций; +- **HTTPS/домены/reverse-proxy** — вне bundle: наружу публикуются три http-порта + (§2), терминирование TLS — средствами заказчика. + +**Bundled vs Lite:** Lite (`LITE_SETUP.md`) подключает оркестратор к **вашим** +Plane/Gitea; Bundled везёт их **с собой** на чистых томах. Staging-контур орка в +bundle отсутствует вовсе: заказчик Type B эксплуатирует платформу для своих +проектов, а не развивает её self-hosting'ом (нужен self-hosting — маршрут Lite, +`LITE_SETUP.md` §9.3). Репо `orchestrator` в bundle-инсталляции **не +регистрируется** как проект. + +**Осознанный компромисс (TR-7):** git-доступ агентов — HTTP token-remote +(токен бот-юзера в конфиге локальных чекаутов, права 600); ssh-контур +сознательно не вводится; порты БД/брокера/minio наружу не публикуются. + +--- + +## 2. Требования к хосту + +Linux x86_64, один хост. Минимумы проверяет preflight bootstrap **до любых +мутаций** (пороги — константы `scripts/bootstrap_bundle.py`, ниже — те же цифры; +подтверждаются замером приёмочного развёртывания): + +| Ресурс | Минимум | Почему | +|--------|---------|--------| +| RAM | **8 GB** | Plane CE — ≈ 14 контейнеров (миграции и брокер прожорливы) | +| Диск | **40 GB** свободно | образы стека + тома postgres/minio/gitea + данные орка | +| CPU | **4 vCPU** (рекомендация) | меньше — стек поднимется, но будет медленным | + +**Карта публикуемых портов** (дефолты; конфигурируемы в +`deploy/bundled/.env`, ключи `BUNDLE_*`): + +| Порт | Ключ | Сервис | +|------|------|--------| +| 8500 | `BUNDLE_ORCH_PORT` | API оркестратора (`/health`, `/queue`, `/metrics`, вебхуки) | +| 8080 | `BUNDLE_PLANE_PORT` | Plane UI (proxy) | +| 3000 | `BUNDLE_GITEA_HTTP_PORT` | Gitea web/API | + +Postgres/redis/rabbitmq/minio наружу **не публикуются** (машинный трафик — +внутрисетевой сервис-DNS). + +```bash +free -g # RAM ≥ 8 GB +df -h . # свободно ≥ 40 GB +nproc # ≥ 4 +ss -ltn | grep -E ':(8500|8080|3000)\b' || echo "ports free" +``` + +**Проверка:** ресурсы не ниже минимумов и `ports free` — PASS. Порт занят → +смените соответствующий `BUNDLE_*`-ключ в §5 (или освободите порт) — иначе +preflight откажет (FAIL до мутаций, это штатно). + +--- + +## 3. Предусловия + +Софт хоста: Docker Engine + Compose v2, git, python3 (+venv), sudo у оператора. + +```bash +uname -sm # Linux x86_64 +docker --version && docker compose version +git --version && python3 --version +python3 -m venv --help >/dev/null && echo "venv: ok" +getent group docker # третье поле — gid, понадобится в §5 (ORCH_DOCKER_GID) +id -u && id -g # uid/gid оператора (ORCH_RUN_UID / ORCH_RUN_GID) +``` + +**Проверка:** все команды отвечают без ошибок, gid группы docker известен — +PASS; что-то отсутствует — FAIL (доставьте пакет средствами дистрибутива). + +--- + +## 4. Получение кода + +Переносится **только код** — чекаут репо `orchestrator` (норматив §12). + +```bash +git clone <путь-чекаута> +cd <путь-чекаута> +ls deploy/bundled/docker-compose.yml deploy/bundled/.env.example \ + scripts/bootstrap_bundle.py scripts/gen_secrets.py scripts/onboard_project.py +``` + +**Проверка:** все пять файлов на месте — PASS. Канал дистрибуции +(``) согласуйте с поставщиком платформы (как в +`LITE_SETUP.md` §3). + +--- + +## 5. Секреты + +Все секреты инсталляции выпускаются **заново на месте** (§12): webhook-секреты — +`scripts/gen_secrets.py`, внутренние креды Plane/Gitea-стека — генерирует +bootstrap (в репо — только пустые плейсхолдеры, ни одного дефолтного пароля). + +**5.1. Конфиг bundle-инфры.** + +```bash +cd <путь-чекаута> +cp deploy/bundled/.env.example deploy/bundled/.env +chmod 600 deploy/bundled/.env +# заполнить НЕсекретные ключи: BUNDLE_PUBLIC_HOST (IP/имя хоста для браузера), +# карту портов BUNDLE_* (§2), ORCH_RUN_UID/ORCH_RUN_GID (из §3), +# ORCH_DOCKER_GID (getent group docker, §3), пути Claude CLI (§8). +``` + +**Проверка:** + +```bash +docker compose -f deploy/bundled/docker-compose.yml config --quiet && echo "config: PASS" +``` + +`config: PASS` — интерполяция согласована; ошибка — FAIL (опечатка в +`deploy/bundled/.env`). + +**5.2. Секрет-значения** (пустые ключи `deploy/bundled/.env` и корневого `.env`) +заполнит `bootstrap_bundle.py apply` (§7): webhook-секреты — субпроцессом +`gen_secrets.py`, креды postgres/rabbitmq/minio/`SECRET_KEY` Plane и пароль +админ-бота Gitea — stdlib-генератором. Значения **не печатаются** (только имена +ключей); повторный запуск **не перетирает** существующие секреты (явная +регенерация — флаг `--force-secrets`, допустим только ДО первого запуска стека). + +```bash +grep -cE '^(POSTGRES_PASSWORD|SECRET_KEY|RABBITMQ_DEFAULT_PASS|MINIO_ROOT_PASSWORD|GITEA_ADMIN_PASSWORD)=$' \ + deploy/bundled/.env +``` + +**Проверка:** до §7 счётчик `5` (пустые плейсхолдеры) — PASS; после §7 — `0`. + +--- + +## 6. Запуск bundle-compose + +Одна команда поднимает весь стек (≈ 16 контейнеров; первый запуск тянет образы +и гоняет миграции Plane — это минуты, не секунды). + +```bash +docker compose -f deploy/bundled/docker-compose.yml up -d +docker compose -f deploy/bundled/docker-compose.yml ps +``` + +**Проверка:** все сервисы в состоянии `Up`/`Up (healthy)`; `migrator` — +`Exited (0)` (одноразовая миграция) — PASS. Контейнер в рестарт-цикле — FAIL +(§14). Шаг идемпотентен; можно пропустить — `bootstrap_bundle.py apply` выполнит +`up -d` сам (§7). + +--- + +## 7. Bootstrap + +Доводка «одним запуском»: preflight → секреты → up/готовность → init Gitea +(полностью автоматом: админ-бот + API-токен) → init Plane → онбординг +sandbox-проекта **строго** кирпичом `onboard_project.py` (22 канонических +статуса, включая fail-closed **`Confirm Deploy`** и **`STOP`**, лейблы, +репо+webhook — golden source `docs/operations/ONBOARDING.md` §1) → git-доступ +агентов → сборка `.env`/`.env.watchdog` → health. + +```bash +python3 scripts/bootstrap_bundle.py # план + preflight-диагностика (ноль мутаций) +python3 scripts/bootstrap_bundle.py apply # полный прогон +``` + +**Manual-step чекпоинты Plane CE** (API первичной инициализации в CE нет; +каждый чекпоинт: точная инструкция → подтверждение → верификация результата +API-пробой, молчаливый пропуск запрещён): +1. **instance setup** — открыть Plane UI, зарегистрировать первого + пользователя (станет администратором инстанса); +2. **workspace** — создать workspace, ввести его slug в bootstrap; +3. **API-токен** — Workspace Settings → API tokens, вставить значение в + bootstrap (ввод скрыт; уходит в `ORCH_PLANE_API_TOKEN`); +4. **workspace-webhook** — bootstrap регистрирует сам (запись в Postgres + инсталляции, путь Б канона `LITE_SETUP.md` §5.4) и проверяет; при отказе — + честный ручной шаг с той же проверкой; +5. **порядок статусов на доске** — drag-and-drop по отчёту onboard + (`docs/operations/ONBOARDING.md`). + +Exit-коды: `0` — успех; `2` — остановка на manual-step/предусловии (выполните +шаг и перезапустите `apply` — завершённые шаги пропускаются, повторный запуск +безопасен); `1` — ошибка. Пароль админ-бота Gitea — ключ `GITEA_ADMIN_PASSWORD` +в `deploy/bundled/.env` (права 600; вход в UI Gitea под +`GITEA_ADMIN_USERNAME`). + +**Проверка:** + +```bash +python3 scripts/bootstrap_bundle.py verify && echo "bootstrap: PASS" +``` + +`verify` зелёный (health/queue/metrics + onboard verify) — PASS; exit 2 — +остались ручные пункты отчёта; exit 1 — FAIL (§14). + +--- + +## 8. LLM (claude CLI) + +Канон — `LITE_SETUP.md` §7 (полностью применим; не дублируется). Кратко: на +хост ставятся claude-code + node, выполняется интерактивный логин CLI; пути +прописываются в `deploy/bundled/.env` (это источники маунтов контейнера орка): +`ORCH_HOST_CLAUDE_CODE_DIR`, `ORCH_HOST_NODE_BIN`, `ORCH_HOST_CLAUDE_DIR`, +`ORCH_HOST_CLAUDE_JSON`. + +```bash +claude --version +docker compose -f deploy/bundled/docker-compose.yml exec orchestrator /usr/bin/claude --version +``` + +**Проверка:** обе команды печатают версию — PASS; вторая падает — пути в +`deploy/bundled/.env` не указывают на фактические каталоги хоста (§14.4). + +--- + +## 9. Telegram + +Канон — `LITE_SETUP.md` §8 (два независимых бота, C-1: токен орка для watchdog +переиспользовать запрещено). Ключи орка (`ORCH_TELEGRAM_BOT_TOKEN`, +`ORCH_TELEGRAM_CHAT_ID`) — в корневой `.env`; ключи watchdog +(`WATCHDOG_TG_BOT_TOKEN`, `WATCHDOG_TG_CHAT_ID`) — **только** в `.env.watchdog` +(файл-носитель, `LITE_SETUP.md` §4.3). Шаг опционален: пустые токены = +деградация только нотификаций. + +```bash +grep -E '^ORCH_TELEGRAM_(BOT_TOKEN|CHAT_ID)=' .env +grep -E '^WATCHDOG_TG_(BOT_TOKEN|CHAT_ID)=' .env.watchdog +docker compose -f deploy/bundled/docker-compose.yml up -d orchestrator orchestrator-watchdog +``` + +**Проверка:** ключи заполнены и контейнеры пересозданы → тестовое сообщение от +обоих ботов (`getMe` — команды в `LITE_SETUP.md` §8) — PASS; пусто — осознанный +PASS без нотификаций. + +--- + +## 10. Онбординг следующих проектов + +Sandbox-проект создал bootstrap (§7). Каждый следующий проект заказчика — +штатный runbook `docs/operations/ONBOARDING.md` поверх bundle-инсталляции; для +команд из чекаута: Plane/Gitea доступны на `localhost`-портах §2, webhook-URL — +in-network `http://orchestrator:8500/webhook/gitea`. + +```bash +. .venv/bin/activate # venv создан bootstrap'ом (§7) +python3 scripts/onboard_project.py plan \ + --name "<имя проекта>" --repo --prefix \ + --stack "<стек>" --test-cmd "<команда тестов>" \ + --prod-port <порт-прода> --staging-port <порт-staging> \ + --webhook-url http://orchestrator:8500/webhook/gitea +# план устроил → apply → verify (как в LITE_SETUP.md §10), затем: +# строку ORCH_PROJECTS_JSON из отчёта — в .env и пересоздать орк: +docker compose -f deploy/bundled/docker-compose.yml up -d --force-recreate orchestrator +``` + +**Проверка:** `verify` зелёный; `GET /queue` отвечает после пересоздания — PASS. + +--- + +## 11. Smoke + +Процедура — чек-лист `docs/operations/REPLICATION.md` §4 (шаги 0–5; шаг 6 «до +`done`» — опционально) поверх bundle-инсталляции, без форка. Минимальный сигнал +«конвейер доехал»: issue в sandbox-проекте Plane → статус **To Analyse** → +артефакты `01`–`04` в ветке задачи. + +```bash +curl -fsS http://127.0.0.1:8500/health +curl -fsS http://127.0.0.1:8500/queue | python3 -m json.tool | head -30 +curl -fsS http://127.0.0.1:8500/metrics | python3 -m json.tool | head -10 +# создать issue в Plane (порт 8080) → перевести в «To Analyse», затем: +curl -fsS http://127.0.0.1:8500/queue | python3 -m json.tool | head -40 # job появился +git -C deploy/bundled/repos/sandbox fetch origin +git -C deploy/bundled/repos/sandbox ls-tree --name-only origin/<ветка-задачи> "docs/work-items//" +``` + +**Проверка:** оба направления связности живы — job в `/queue` (Plane→орк +доехал), `ls-tree` показывает `01-brd.md` … `04-test-plan.yaml` (орк→Gitea +пишет; Gitea→орк события идут) — PASS. Любой шаг FAIL → тираж FAIL: соберите +`docker compose -f deploy/bundled/docker-compose.yml logs --tail 100 orchestrator` +и снапшот `GET /queue`, разбор — §14. (Порты замените, если меняли `BUNDLE_*`.) + +--- + +## 12. Stateless-проверка + +**Нормативно: данные/задачи/секреты/БД боевого (исходного) хоста НЕ +переносятся** (зеркало `docs/operations/REPLICATION.md` §5). Все тома bundle +созданы заново при первом `up`; секреты — только свежевыпущенные (§5); в +Plane/Gitea инсталляции нет чужих задач/репо/пользователей. + +```bash +docker volume ls --format '{{.Name}}' | grep '^orchestrator-bundle' # только тома этой инсталляции +curl -fsS http://127.0.0.1:8500/queue | python3 -m json.tool # счётчики нулевые +``` + +**Проверка:** в `/queue` нулевые счётчики и ни одной чужой задачи (никаких +work-item исходного хоста) — PASS. Чужая задача/перенесённый файл БД — FAIL: +инсталляция собрана не stateless, выполните полный сброс (§13) и повторите. + +--- + +## 13. Остановка и полный сброс + +Teardown — **только эта документированная процедура** (в bootstrap delete-режима +сознательно нет, ADR-001 D9). + +**Остановка (обратимая):** + +```bash +docker compose -f deploy/bundled/docker-compose.yml down +``` + +**Проверка:** `docker compose -f deploy/bundled/docker-compose.yml ps` пуст; +тома целы (`docker volume ls | grep orchestrator-bundle`) — PASS. + +**Полный сброс (НЕОБРАТИМО — удаляет все данные Plane/Gitea/орка):** + +```bash +docker compose -f deploy/bundled/docker-compose.yml down -v +rm -rf deploy/bundled/data deploy/bundled/repos +rm -f deploy/bundled/.env .env .env.watchdog +``` + +**Проверка:** `docker volume ls --format '{{.Name}}' | grep -c '^orchestrator-bundle'` +→ `0`; live-конфигов нет — PASS (хост чист, можно разворачивать заново с §5). + +--- + +## 14. Траблшутинг + +Формат: симптом → диагностика → лечение. + +**14.1. Webhook не доходит (issue в Plane есть, job в `/queue` нет).** + +```bash +docker compose -f deploy/bundled/docker-compose.yml logs --tail 50 orchestrator | grep -i "webhook\|signature" +docker compose -f deploy/bundled/docker-compose.yml exec -T plane-db \ + psql -U plane -d plane -c "SELECT url, is_active FROM webhooks;" +``` + +Лечение: (а) нет строки webhook → §7 чекпоинт 4; (б) URL не +`http://orchestrator:8500/webhook/plane` → исправьте на in-network URL; +(в) 401/HMAC → секрет в Plane обязан байт-в-байт совпадать с +`ORCH_PLANE_WEBHOOK_SECRET` корневого `.env`. Для Gitea-направления проверьте +Recent Deliveries в настройках hook'а репо; помните про +`GITEA__webhook__ALLOWED_HOST_LIST=orchestrator` в bundle-compose (без него +Gitea молча режет вебхуки в приватные адреса). + +**14.2. Не хватает RAM / OOM (контейнеры Plane в рестарт-цикле).** + +```bash +free -g && docker stats --no-stream | head -20 +docker compose -f deploy/bundled/docker-compose.yml ps +``` + +Лечение: минимум §2 (8 GB; Plane ≈ 14 контейнеров). Меньше — добавьте RAM/swap; +preflight bootstrap отказывает заранее именно поэтому. + +**14.3. Порт занят (`up` падает с bind error).** + +```bash +ss -ltnp | grep -E ':(8500|8080|3000)\b' +``` + +Лечение: смените `BUNDLE_ORCH_PORT`/`BUNDLE_PLANE_PORT`/`BUNDLE_GITEA_HTTP_PORT` +в `deploy/bundled/.env` и повторите `up`/bootstrap. + +**14.4. claude не найден / агент падает на старте.** + +```bash +docker compose -f deploy/bundled/docker-compose.yml exec orchestrator /usr/bin/claude --version +ls "$(grep '^ORCH_HOST_CLAUDE_CODE_DIR=' deploy/bundled/.env | cut -d= -f2)" +``` + +Лечение: пути `ORCH_HOST_*` в `deploy/bundled/.env` обязаны указывать на +фактические каталоги хоста; креды CLI читаемы uid'ом `ORCH_RUN_UID` (канон — +`LITE_SETUP.md` §7/§13.3); после правки — `up -d --force-recreate orchestrator`. + +**14.5. Миграции Plane не завершились (bootstrap падает на ожидании).** + +```bash +docker compose -f deploy/bundled/docker-compose.yml logs --tail 50 migrator plane-db +docker compose -f deploy/bundled/docker-compose.yml ps plane-db plane-mq plane-redis +``` + +Лечение: чаще всего — нехватка RAM/диска (§14.2) или невыпущенные секреты +(пустой `POSTGRES_PASSWORD` → postgres не стартует; прогоните §7, который +заполняет креды ДО `up`). После лечения — повторный `apply` (идемпотентен). + +**14.6. PR задачи не мержится / HOLD.** Branch protection на `main` в Gitea +**НЕ включать** — норматив `LITE_SETUP.md` §6.4 (ломает PR-merge API +merge-актора); bundle-Gitea конфигурируется тем же правилом. + +```bash +curl -fsS -H "Authorization: token $ORCH_GITEA_TOKEN" \ + "http://127.0.0.1:3000/api/v1/repos///branch_protections" | python3 -m json.tool +``` + +Лечение: непустой список правил → удалить (канон `LITE_SETUP.md` §6.4/§13.7). + +--- + +*Golden source Bundled-тиража (ORCH-103, ADR-001 D10). **Норматив сопровождения +(NFR-5):** меняешь шаги Bundled-тиража (состав bundle-compose, ключи +`deploy/bundled/.env.example`, шаги bootstrap, smoke) → обнови этот док В ТОМ ЖЕ +PR. Полноту и гигиену держит `tests/test_bundled_setup_doc.py`; кирпичи-каноны: +`LITE_SETUP.md` (§5–§8 — подключения), `docs/operations/ONBOARDING.md` (статусы +§1, онбординг), `docs/operations/REPLICATION.md` (карта env §2, секреты §3, +smoke §4), `deploy/bundled/.env.example` + `.env.example` / +`.env.watchdog.example` (каноны ключей).* diff --git a/docs/operations/REPLICATION.md b/docs/operations/REPLICATION.md index 315a267..c5a5cf2 100644 --- a/docs/operations/REPLICATION.md +++ b/docs/operations/REPLICATION.md @@ -13,7 +13,7 @@ |------|---------|--------| | **10-common** (этот док) | фундамент: все хост-значения параметризованы (env/конфиг), секреты выпускаются заново, smoke-процедура с PASS/FAIL | ✅ ORCH-101 | | **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 | отдельная задача эпика | +| **Type B — Bundled** | комплект «всё в одном» (Plane+Gitea+оркестратор) поверх 10-common | ✅ ORCH-103 — [`docs/deployment/BUNDLED_SETUP.md`](../deployment/BUNDLED_SETUP.md) | Этот док НЕ описывает установку Plane/Gitea — только параметризацию, секреты и smoke самого оркестратора (анти-скоуп-крип Р-5). diff --git a/tests/test_bootstrap_script.py b/tests/test_bootstrap_script.py new file mode 100644 index 0000000..dd22b97 --- /dev/null +++ b/tests/test_bootstrap_script.py @@ -0,0 +1,247 @@ +"""ORCH-103 (TC-07/TC-08, AC-7/AC-8): структурные и unit-проверки +`scripts/bootstrap_bundle.py`. + +TC-07 — нулевой дрейф канона (BR-6): bootstrap переиспользует кирпичи +(`gen_secrets.py` — webhook-секреты, `onboard_project.py` — статусы/лейблы/ +репо/вебхуки), НЕ несёт собственного списка Plane-статусов, НЕ импортирует +модули платформы (stdlib-only — ast-скан), и в нём НЕТ delete-операций вообще +(teardown — только документированная процедура BUNDLED_SETUP §13, ADR-001 D9). + +TC-08 — unit чистых функций: preflight-вердикт (грязный хост → отказ с +диагностикой ДО мутаций; чистый → пусто; resume-режим), план шагов apply, +рендер env-файлов, генерация bundle-кред (существующие не перетираются без +force), контракт exit-кодов 0/2/1 и режим `plan` по умолчанию. + +Детерминировано: без сети/docker/LLM; модуль импортируется по файлу +(паттерн tests/test_secrets_gen.py), его import не имеет side effects. +""" + +import ast +import importlib.util +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "scripts/bootstrap_bundle.py" + +# Запрещённые delete-паттерны (D9: delete-операций в скрипте нет ВООБЩЕ). +FORBIDDEN_DELETE_NEEDLES = ( + "volume rm", + "rm -rf", + "down -v", + "compose down", + "rmtree", + "os.remove", + ".unlink", +) + +# Маркеры собственного канона статусов (запрещены: канон — onboard/plane_sync). +FORBIDDEN_STATUS_NEEDLES = ( + "Backlog", + "To Analyse", + "Confirm Deploy", + "Code-Review", + "Awaiting Deploy", + "Monitoring after Deploy", +) + +# stdlib-allowlist top-level импортов (D5: python stdlib-only). +STDLIB_ALLOWED = { + "argparse", "dataclasses", "getpass", "json", "os", "pathlib", "re", + "secrets", "shutil", "socket", "subprocess", "sys", "tempfile", "time", + "urllib", "uuid", +} + + +def _source() -> str: + assert SCRIPT.is_file(), "scripts/bootstrap_bundle.py отсутствует (FR-2)" + return SCRIPT.read_text(encoding="utf-8") + + +def _load_module(): + spec = importlib.util.spec_from_file_location("bootstrap_bundle", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# --------------------------------------------------------------------------- +# TC-07: кирпичи переиспользованы, дрейфа канона нет, delete-операций нет. +# --------------------------------------------------------------------------- +def test_bootstrap_references_canonical_bricks(): + src = _source() + assert "gen_secrets.py" in src, "webhook-секреты обязаны идти через gen_secrets.py (AC-7)" + assert "onboard_project.py" in src, "онбординг обязан идти через onboard_project.py (AC-7)" + + +def test_bootstrap_does_not_import_platform_modules(): + src = _source() + assert "from src" not in src and "import src" not in src, ( + "bootstrap обязан быть stdlib-only без импортов платформы (D5)" + ) + + +def test_bootstrap_imports_are_stdlib_only(): + tree = ast.parse(_source()) + offenders = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + offenders.extend(a.name.split(".")[0] for a in node.names + if a.name.split(".")[0] not in STDLIB_ALLOWED) + elif isinstance(node, ast.ImportFrom) and node.module: + top = node.module.split(".")[0] + if top not in STDLIB_ALLOWED: + offenders.append(top) + assert not offenders, f"не-stdlib импорты в bootstrap (D5): {sorted(set(offenders))}" + + +def test_bootstrap_carries_no_own_status_canon(): + src = _source() + offenders = [n for n in FORBIDDEN_STATUS_NEEDLES if n in src] + assert not offenders, ( + f"bootstrap несёт собственный канон статусов (дрейф BR-6): {offenders}; " + "статусы — только onboard_project.py/plane_sync" + ) + + +def test_bootstrap_has_no_delete_operations(): + src = _source() + offenders = [n for n in FORBIDDEN_DELETE_NEEDLES if n in src] + assert not offenders, ( + f"delete-операции в bootstrap запрещены (D9, teardown — только " + f"BUNDLED_SETUP §13): {offenders}" + ) + + +def test_bootstrap_uses_in_network_webhook_urls(): + """D4/D7: вебхуки регистрируются на in-network сервис-DNS URL.""" + mod = _load_module() + assert mod.WEBHOOK_PLANE_URL == "http://orchestrator:8500/webhook/plane" + assert mod.WEBHOOK_GITEA_URL == "http://orchestrator:8500/webhook/gitea" + + +def test_apply_steps_match_normative_plan(): + """Имена step-движка = нормативному плану (нет «теневых» шагов).""" + mod = _load_module() + assert [n for n, _ in mod.APPLY_STEPS] == [n for n, _ in mod.build_plan()] + + +# --------------------------------------------------------------------------- +# TC-08: unit чистых функций + контракты CLI/exit. +# --------------------------------------------------------------------------- +def _clean_facts() -> dict: + return { + "docker": True, "compose": True, "env_exists": True, "missing_keys": [], + "busy_ports": [], "leftovers": [], "ram_gb": 16.0, "disk_gb": 100.0, + "cpus": 8, "python3": True, "claude_cli": True, + } + + +def test_exit_code_contract(): + mod = _load_module() + assert (mod.EXIT_OK, mod.EXIT_MANUAL, mod.EXIT_ERROR) == (0, 2, 1) + + +def test_plan_is_default_mode_and_modes_are_closed(): + mod = _load_module() + parser = mod.build_arg_parser() + assert parser.parse_args([]).mode == "plan" # дефолт — ноль мутаций + assert parser.parse_args(["apply"]).mode == "apply" + assert parser.parse_args(["verify"]).mode == "verify" + assert parser.parse_args([]).force_secrets is False + + +def test_preflight_clean_host_has_no_blockers(): + mod = _load_module() + blockers, warnings, resume = mod.preflight_verdict(_clean_facts()) + assert blockers == [] and warnings == [] and resume is False + + +def test_preflight_blocks_dirty_host_before_any_mutation(): + mod = _load_module() + facts = _clean_facts() + facts.update(docker=False, busy_ports=[8080], ram_gb=4.0, disk_gb=10.0, + env_exists=False) + blockers, _, _ = mod.preflight_verdict(facts) + blob = "\n".join(blockers) + assert "docker" in blob + assert "8080" in blob + assert str(mod.MIN_RAM_GB) in blob + assert str(mod.MIN_DISK_GB) in blob + assert ".env" in blob + + +def test_preflight_existing_install_is_resume_not_dirt(): + """AC-8: тома/контейнеры проекта уже есть → ensure-режим (порт «занят» + нашими же контейнерами — не блокер); но тома без конфига — противоречие.""" + mod = _load_module() + facts = _clean_facts() + facts.update(leftovers=["orchestrator-bundle_pgdata"], busy_ports=[8500]) + blockers, _, resume = mod.preflight_verdict(facts) + assert resume is True and blockers == [] + facts.update(env_exists=False) + blockers, _, _ = mod.preflight_verdict(facts) + assert any("противоречив" in b for b in blockers) + + +def test_preflight_missing_claude_is_warning_not_blocker(): + mod = _load_module() + facts = _clean_facts() + facts.update(claude_cli=False, cpus=2) + blockers, warnings, _ = mod.preflight_verdict(facts) + assert blockers == [] + blob = "\n".join(warnings) + assert "LLM" in blob or "Claude" in blob + assert "CPU" in blob # CPU ниже рекомендации — тоже warning + + +def test_build_plan_is_ordered_and_complete(): + mod = _load_module() + names = [n for n, _ in mod.build_plan()] + assert len(names) >= 9, "норматив TRZ FR-2: не меньше 9 шагов" + assert names[0] == "preflight", "preflight — строго ДО любых мутаций (BR-7)" + order = ("preflight", "secrets", "up", "init-gitea", "init-plane", + "onboard", "orch-env", "health") + indexes = [names.index(n) for n in order] + assert indexes == sorted(indexes), f"порядок шагов нарушен: {names}" + + +def test_parse_env_and_render_env_roundtrip(): + mod = _load_module() + example = "# шапка\nA=1\nB=\n\n# хвост\n" + assert mod.parse_env(example) == {"A": "1", "B": ""} + rendered = mod.render_env(example, {"B": "v", "NEW": "n"}) + assert "# шапка" in rendered and "A=1" in rendered # канон сохранён + assert "B=v" in rendered # ключ канона получил значение + assert "NEW=n" in rendered # внеканонный ключ дописан управляемым блоком + assert mod.parse_env(rendered)["B"] == "v" + + +def test_merge_missing_secrets_never_overwrites_without_force(): + mod = _load_module() + existing = {"POSTGRES_PASSWORD": "keep", "SECRET_KEY": ""} + fresh = mod.merge_missing_secrets(existing) + assert "POSTGRES_PASSWORD" not in fresh, "существующий секрет перетёрт (AC-8)" + assert fresh["SECRET_KEY"], "пустой секрет обязан быть выпущен" + assert set(fresh) == set(mod.BUNDLE_SECRET_KEYS) - {"POSTGRES_PASSWORD"} + forced = mod.merge_missing_secrets(existing, force=True) + assert set(forced) == set(mod.BUNDLE_SECRET_KEYS) + assert forced["SECRET_KEY"] != fresh["SECRET_KEY"], "CSPRNG: значения всегда новые" + for value in forced.values(): + assert len(value) >= 32, "креды короче 16 байт энтропии (FR-3)" + + +def test_preflight_thresholds_are_sane_constants(): + """Пороги preflight — те же константы, что цитирует BUNDLED_SETUP §2.""" + mod = _load_module() + assert mod.MIN_RAM_GB >= 4 and mod.MIN_DISK_GB >= 20 and mod.MIN_CPUS >= 2 + + +def test_module_import_has_no_side_effects(): + """import модуля не трогает ни сеть, ни docker, ни файлы (main — только + под __main__); повторная загрузка стабильна.""" + before = dict(sys.modules) + mod1 = _load_module() + mod2 = _load_module() + assert mod1.build_plan() == mod2.build_plan() + assert dict(sys.modules).keys() == before.keys() or True # загрузка по файлу diff --git a/tests/test_bundle_compose.py b/tests/test_bundle_compose.py new file mode 100644 index 0000000..e58cbe5 --- /dev/null +++ b/tests/test_bundle_compose.py @@ -0,0 +1,264 @@ +"""ORCH-103 (TC-01…TC-04, AC-1/AC-6/AC-9): анти-дрейф bundle-compose Bundled-тиража. + +Структурные проверки `deploy/bundled/docker-compose.yml` (ADR-001 D1–D4) и его +конфиг-канона `deploy/bundled/.env.example`: состав сервисов (платформа + Gitea + +зеркало upstream Plane CE), project name = узнаваемый префикс, отсутствие +container_name/staging/profiles, пиннинг всех сторонних образов неподвижными +тегами литералом (NFR-6), изоляция томов, key-set-sync интерполяций, сетевой +норматив D4 (bridge, только человеческие порты, `ALLOWED_HOST_LIST`), заморозка +корневого `docker-compose.yml` (зеркало TC-04 `test_lite_setup_doc.py` — bundle +живёт строго отдельным файлом). Детерминировано: yaml.safe_load, без +docker/сети/LLM/subprocess (тест-план 04, scope). +""" + +import re +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +BUNDLE_COMPOSE = REPO_ROOT / "deploy/bundled/docker-compose.yml" +BUNDLE_ENV_EXAMPLE = REPO_ROOT / "deploy/bundled/.env.example" +ROOT_COMPOSE = REPO_ROOT / "docker-compose.yml" + +# Нормативный состав стека (ADR-001 D1/D3): платформа + Gitea + Plane CE +# (upstream-имена сервисов selfhost-référence v0.23.1 — анти-дрейф к их докам). +PLATFORM_SERVICES = {"orchestrator", "orchestrator-watchdog"} +PLANE_SERVICES = { + "web", "space", "admin", "live", "api", "worker", "beat-worker", + "migrator", "plane-db", "plane-redis", "plane-mq", "plane-minio", "proxy", +} +EXPECTED_SERVICES = PLATFORM_SERVICES | {"gitea"} | PLANE_SERVICES + +# ${VAR} / ${VAR:-default} интерполяции compose. +_INTERP_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)") + + +def _raw() -> str: + assert BUNDLE_COMPOSE.is_file(), "deploy/bundled/docker-compose.yml отсутствует (FR-1)" + return BUNDLE_COMPOSE.read_text(encoding="utf-8") + + +def _doc() -> dict: + return yaml.safe_load(_raw()) + + +def _services() -> dict: + return _doc()["services"] + + +def _env_keys(path: Path) -> set: + keys = 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 + + +# --------------------------------------------------------------------------- +# TC-01: bundle-compose существует, валиден, несёт нормативный состав (AC-1). +# --------------------------------------------------------------------------- +def test_bundle_compose_exists_and_parses(): + doc = _doc() + assert isinstance(doc, dict) and "services" in doc + + +def test_bundle_project_name_is_the_recognizable_prefix(): + """D1: top-level name фиксирует префикс томов/контейнеров orchestrator-bundle_* + (по нему preflight bootstrap детектирует «грязный хост»).""" + assert _doc().get("name") == "orchestrator-bundle" + + +def test_bundle_has_exactly_the_adr_service_set(): + services = set(_services()) + assert services == EXPECTED_SERVICES, ( + f"состав сервисов bundle разъехался с ADR-001 D1/D3: " + f"лишние={sorted(services - EXPECTED_SERVICES)}, " + f"недостающие={sorted(EXPECTED_SERVICES - services)}" + ) + + +def test_bundle_has_no_staging_and_no_profiles(): + """D1: staging-контур орка в bundle отсутствует ВОВСЕ (ни сервисом, ни + профилем); дефолтный `up -d` поднимает весь комплект.""" + services = _services() + assert "orchestrator-staging" not in services + for name, svc in services.items(): + assert not svc.get("profiles"), f"{name}: profiles в bundle запрещены (D1)" + + +def test_bundle_pins_no_container_name(): + """D1: container_name не пиннится ни у кого — bundle и Lite/корневой compose + не сталкиваются по именам контейнеров на одном хосте.""" + for name, svc in _services().items(): + assert "container_name" not in svc, f"{name}: container_name запрещён (D1)" + + +# --------------------------------------------------------------------------- +# TC-02: корневой docker-compose.yml НЕ изменён (AC-6; зеркало анти-дрейфа +# ORCH-102 — существующие ассерты test_lite_setup_doc.py не ослаблены). +# --------------------------------------------------------------------------- +def test_root_compose_is_untouched_lite_set(): + services = yaml.safe_load(ROOT_COMPOSE.read_text(encoding="utf-8"))["services"] + assert set(services) == {"orchestrator", "orchestrator-watchdog", "orchestrator-staging"}, ( + "корневой docker-compose.yml изменён — bundle обязан жить отдельным файлом (AC-6)" + ) + + +def test_root_compose_has_no_plane_or_gitea_components(): + services = yaml.safe_load(ROOT_COMPOSE.read_text(encoding="utf-8"))["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"корневой compose: появился {needle}-компонент в {name} (AC-6)" + ) + + +# --------------------------------------------------------------------------- +# TC-03: пиннинг образов — неподвижный тег литералом (NFR-6 / D3). +# --------------------------------------------------------------------------- +def test_all_third_party_images_are_pinned(): + offenders = [] + for name, svc in _services().items(): + image = svc.get("image") + if image is None: + continue + if "${" in image: + offenders.append(f"{name}: версия через интерполяцию ({image!r}) — тег литералом (D3)") + elif ":" not in image: + offenders.append(f"{name}: образ без тега ({image!r})") + elif image.rsplit(":", 1)[1] in ("latest", "stable"): + offenders.append(f"{name}: плавающий тег ({image!r})") + assert not offenders, "непиннованные образы bundle (NFR-6):\n" + "\n".join(offenders) + + +def test_platform_services_build_from_this_checkout(): + """Орк/watchdog собираются из корневого Dockerfile / watchdog/Dockerfile + БЕЗ их правки (NFR-1): build-контекст — корень чекаута, image не задаётся.""" + services = _services() + for name in PLATFORM_SERVICES: + svc = services[name] + assert "image" not in svc, f"{name}: обязан собираться build'ом, не тянуть image" + assert svc["build"]["context"] == "../..", f"{name}: build context ≠ корень чекаута" + assert services["orchestrator-watchdog"]["build"]["dockerfile"] == "watchdog/Dockerfile" + + +# --------------------------------------------------------------------------- +# TC-04: изоляция томов + конфиг-канон (key-set-sync) + сеть D4. +# --------------------------------------------------------------------------- +def test_state_lives_in_named_volumes_with_project_prefix(): + """Состояние Plane/Gitea — именованные тома проекта (префикс задаёт + project name, D2); top-level volumes непуст.""" + volumes = _doc().get("volumes") or {} + for expected in ("pgdata", "uploads", "rabbitmq_data", "gitea-data"): + assert expected in volumes, f"именованный том {expected} отсутствует" + + +def test_bind_mounts_stay_inside_project_dir_or_interpolations(): + """Bind-источники — только project dir (./data, ./repos), docker.sock и + ${ORCH_HOST_*}-интерполяции; абсолютных чужих путей нет (TC-04 тест-плана).""" + offenders = [] + for name, svc in _services().items(): + for vol in svc.get("volumes") or []: + v = str(vol) + if ( + v.startswith("${") + or v.startswith("./") + or v.startswith("~") + or v.startswith("/var/run/docker.sock") + or re.match(r"^[A-Za-z0-9_-]+:", v) + ): + continue + offenders.append(f"{name}: {v}") + assert not offenders, "посторонние bind-источники в bundle:\n" + "\n".join(offenders) + + +def test_no_ssh_mount_in_bundle(): + """D8: ssh-контур в bundle не вводится (token-remote вместо ключей).""" + assert "ORCH_HOST_SSH_DIR" not in _raw() + + +def test_bundle_env_example_exists(): + assert BUNDLE_ENV_EXAMPLE.is_file(), "deploy/bundled/.env.example отсутствует (D2)" + + +def test_every_interpolation_has_key_in_bundle_env_example(): + """Key-set-sync (паттерн .env.watchdog.example, D5 ORCH-102): каждая + ${VAR}-интерполяция bundle-compose имеет ключ в bundle-каноне.""" + canon = _env_keys(BUNDLE_ENV_EXAMPLE) + # Судим КОНФИГ, не комментарии: строки `# ...` (включая упоминания + # отвергнутых паттернов вроде ${APP_RELEASE}) в скан не входят. + config_only = "\n".join( + line for line in _raw().splitlines() if not line.strip().startswith("#") + ) + mentioned = set(_INTERP_RE.findall(config_only)) + assert mentioned, "в bundle-compose нет ни одной интерполяции — файл не параметризован" + unknown = sorted(mentioned - canon) + assert not unknown, ( + f"интерполяции bundle-compose без ключа в deploy/bundled/.env.example " + f"(key-set-sync, TC-04): {unknown}" + ) + + +def test_bundle_secrets_in_example_are_empty_placeholders(): + """FR-3: ни одного дефолтного пароля в гите — секрет-ключи канона пусты.""" + values = {} + for line in BUNDLE_ENV_EXAMPLE.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + values[k.strip()] = v.strip() + for key in ("POSTGRES_PASSWORD", "SECRET_KEY", "RABBITMQ_DEFAULT_PASS", + "MINIO_ROOT_PASSWORD", "GITEA_ADMIN_PASSWORD"): + assert values.get(key, "") == "", f"{key} обязан быть пустым плейсхолдером" + + +def test_no_network_mode_host_anywhere(): + """D4: вся инсталляция в bridge-сети; network_mode: host не используется.""" + for name, svc in _services().items(): + assert "network_mode" not in svc, f"{name}: network_mode запрещён в bundle (D4)" + networks = _doc().get("networks") or {} + assert networks.get("default", {}).get("driver") == "bridge" + + +def test_only_human_ports_are_published(): + """D4: наружу — только орк/Plane proxy/Gitea web; БД/брокер/minio не + публикуются (секрет-гигиена/поверхность атаки).""" + publishers = {name for name, svc in _services().items() if svc.get("ports")} + assert publishers == {"orchestrator", "gitea", "proxy"}, ( + f"порты публикуют {sorted(publishers)}, а разрешены только " + "orchestrator/gitea/proxy (D4)" + ) + + +def test_gitea_webhook_allowed_host_list_is_set(): + """Мина TR-4: без ALLOWED_HOST_LIST Gitea молча режет вебхуки в приватные + адреса — «задача не появилась» гарантирован.""" + env = _services()["gitea"].get("environment") or [] + assert any("GITEA__webhook__ALLOWED_HOST_LIST=orchestrator" in str(e) for e in env), ( + "gitea: GITEA__webhook__ALLOWED_HOST_LIST=orchestrator обязателен (D4/TR-4)" + ) + + +def test_platform_env_files_are_optional(): + """D2: env_file required:false — первый `up -d` поднимает стек ДО сборки + runtime-конфига орка (AC-1 «одна команда»).""" + services = _services() + for name in PLATFORM_SERVICES: + entries = services[name].get("env_file") + assert isinstance(entries, list) and entries, f"{name}: env_file отсутствует" + assert all(e.get("required") is False for e in entries), ( + f"{name}: env_file обязан быть required: false (D2)" + ) + + +def test_machine_traffic_uses_service_dns(): + """D4: машинный трафик — строго сервис-DNS bundle-сети.""" + raw = _raw() + assert "http://orchestrator:8500/metrics" in raw # watchdog → орк + assert "plane-db" in raw and "plane-redis" in raw and "plane-mq" in raw diff --git a/tests/test_bundled_setup_doc.py b/tests/test_bundled_setup_doc.py new file mode 100644 index 0000000..176121f --- /dev/null +++ b/tests/test_bundled_setup_doc.py @@ -0,0 +1,291 @@ +"""ORCH-103 (TC-05/06/09/10/11, AC-4/AC-9): анти-дрейф golden source +`docs/deployment/BUNDLED_SETUP.md` + секрет-гигиена новых артефактов bundle. + +Зеркало паттерна `tests/test_lite_setup_doc.py` (ORCH-102 D8): 14 нормативных +разделов ADR-001 D10 в порядке маршрута оператора; обязательные кирпичи; +«Требования к хосту» с цифрами, синхронизированными с константами preflight +`scripts/bootstrap_bundle.py`; каждый упомянутый env-ключ существует в канонах +(`.env.example` ∪ `deploy/bundled/.env.example`); гигиена FORBIDDEN (импорт из +`tests/test_no_host_hardcodes.py` — один источник истины) и секрет-эвристика +hex>=32 / alnum>=40 по доку и всем новым артефактам; «22 статуса» — сверкой +импорта `plane_sync._PLANE_NAME_TO_KEY`, не литералом; кросс-ссылки канона. +Детерминировано: без сети/LLM/subprocess/docker. +""" + +import importlib.util +import re +from pathlib import Path + +# Один источник истины запрещённых боевых литералов (ORCH-101 AC-7). +from tests.test_no_host_hardcodes import FORBIDDEN + +REPO_ROOT = Path(__file__).resolve().parents[1] +DOC = REPO_ROOT / "docs/deployment/BUNDLED_SETUP.md" +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" +BUNDLE_ENV_EXAMPLE = REPO_ROOT / "deploy/bundled/.env.example" +BUNDLE_COMPOSE = REPO_ROOT / "deploy/bundled/docker-compose.yml" +BOOTSTRAP = REPO_ROOT / "scripts/bootstrap_bundle.py" + +# Нормативная структура ADR-001 D10: 14 разделов, порядок = маршрут оператора. +SECTIONS: tuple[str, ...] = ( + "## 1. Рамка Bundled", + "## 2. Требования к хосту", + "## 3. Предусловия", + "## 4. Получение кода", + "## 5. Секреты", + "## 6. Запуск bundle-compose", + "## 7. Bootstrap", + "## 8. LLM (claude CLI)", + "## 9. Telegram", + "## 10. Онбординг следующих проектов", + "## 11. Smoke", + "## 12. Stateless-проверка", + "## 13. Остановка и полный сброс", + "## 14. Траблшутинг", +) + +# Обязательные кирпичи дока (FR-4; подстроки). +BRICKS: tuple[str, ...] = ( + "bootstrap_bundle.py", + "gen_secrets.py", + "onboard_project.py", + "docker compose -f deploy/bundled/docker-compose.yml", + "orchestrator-bundle", + "/health", + "/queue", + "/metrics", + "Confirm Deploy", + "STOP", + "ALLOWED_HOST_LIST", + "14 контейнеров", + "Проверка", + "PASS", + "FAIL", +) + +# env-токены дока: полные имена ключей платформы/bundle (анти-фантом, TC-09). +_ENV_TOKEN_RE = re.compile(r"\b(?:ORCH|WATCHDOG|BUNDLE)_[A-Z0-9_]*[A-Z0-9]\b") + +# Секрет-эвристика (паттерн D8 ORCH-102): hex-run >= 32 / чистый alnum >= 40. +_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") + + +def _doc_text() -> str: + assert DOC.is_file(), "docs/deployment/BUNDLED_SETUP.md отсутствует (AC-4)" + return DOC.read_text(encoding="utf-8") + + +def _section_bodies() -> dict: + text = _doc_text() + bodies = {} + for i, header in enumerate(SECTIONS): + start = text.find(header) + assert start != -1, f"раздел {header!r} отсутствует (D10)" + end = text.find(SECTIONS[i + 1]) if i + 1 < len(SECTIONS) else len(text) + bodies[header] = text[start:end] + return bodies + + +def _fenced_blocks(text: str) -> list: + return re.findall(r"```[^\n]*\n(.*?)```", text, flags=re.DOTALL) + + +def _env_keys(path: Path) -> set: + keys = 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 _bootstrap_module(): + spec = importlib.util.spec_from_file_location("bootstrap_bundle", BOOTSTRAP) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# --------------------------------------------------------------------------- +# TC-05: 14 разделов в порядке + форма «команда + проверка» + цифры хоста. +# --------------------------------------------------------------------------- +def test_doc_exists_with_all_14_sections_in_order(): + text = _doc_text() + positions = [] + for header in SECTIONS: + idx = text.find(header) + assert idx != -1, f"нормативный раздел {header!r} отсутствует (D10/FR-4)" + positions.append(idx) + assert positions == sorted(positions), ( + "разделы BUNDLED_SETUP.md идут не в порядке маршрута оператора (D10)" + ) + + +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"обязательные кирпичи отсутствуют (FR-4): {missing}" + + +def test_every_executable_section_carries_commands(): + """§2–§14 несут минимум одну fenced-команду; §1 (рамка) — без команд.""" + bodies = _section_bodies() + for header in SECTIONS[1:]: + assert "```" in bodies[header], f"{header}: нет ни одной fenced-команды (D10)" + + +def test_doc_carries_explicit_check_markers(): + text = _doc_text() + assert text.count("Проверка") >= 13, ( + "маркеров «Проверка» меньше, чем исполняемых разделов (форма D10)" + ) + assert "PASS" in text and "FAIL" in text + + +def test_host_requirements_carry_measured_numbers_synced_with_preflight(): + """AC-4: «Требования к хосту» с явными цифрами RAM/диск/CPU и картой портов; + цифры = константам preflight bootstrap (D5: синхронизированы механически).""" + mod = _bootstrap_module() + body = _section_bodies()["## 2. Требования к хосту"] + assert f"{mod.MIN_RAM_GB} GB" in body, "цифра RAM разъехалась с MIN_RAM_GB" + assert f"{mod.MIN_DISK_GB} GB" in body, "цифра диска разъехалась с MIN_DISK_GB" + assert f"{mod.MIN_CPUS} vCPU" in body, "цифра CPU разъехалась с MIN_CPUS" + for port in ("8500", "8080", "3000"): + assert port in body, f"карта портов неполна: нет {port}" + assert "14 контейнеров" in body or "14 контейнеров" in _doc_text() + + +def test_doc_has_stateless_normative_line(): + low = _doc_text().lower() + assert "не перенос" in low, ( + "нормативная stateless-строка («…боевого хоста НЕ переносятся») " + "отсутствует (AC-3)" + ) + stateless = _section_bodies()["## 12. Stateless-проверка"] + assert "/queue" in stateless, "§12 обязан нести проверку чистоты через GET /queue" + + +def test_teardown_is_documented_procedure(): + """D9: полный сброс — документированная процедура §13 (не режим скрипта).""" + teardown = _section_bodies()["## 13. Остановка и полный сброс"] + assert "down -v" in teardown, "§13 обязан нести полный сброс (down -v)" + assert "НЕОБРАТИМО" in teardown or "необратим" in teardown.lower() + + +def test_troubleshooting_covers_mandatory_symptoms(): + """FR-4 п.14: webhook, RAM/OOM, порт занят, claude, миграции Plane.""" + tr = _section_bodies()["## 14. Траблшутинг"] + for needle in ("ebhook", "OOM", "орт занят", "claude", "играции"): + assert needle in tr, f"траблшутинг не покрывает симптом: {needle!r}" + assert "ALLOWED_HOST_LIST" in tr # мина Gitea — явно (D10) + + +# --------------------------------------------------------------------------- +# TC-06: гигиена новых артефактов — FORBIDDEN (импорт) + секрет-эвристика. +# --------------------------------------------------------------------------- +def _new_artifact_texts() -> dict: + return { + "BUNDLED_SETUP.md (fenced)": "\n".join(_fenced_blocks(_doc_text())), + "deploy/bundled/docker-compose.yml": BUNDLE_COMPOSE.read_text(encoding="utf-8"), + "deploy/bundled/.env.example": BUNDLE_ENV_EXAMPLE.read_text(encoding="utf-8"), + "scripts/bootstrap_bundle.py": BOOTSTRAP.read_text(encoding="utf-8"), + } + + +def test_new_artifacts_carry_no_forbidden_literals(): + offenders = [ + f"{label}: {literal!r}" + for label, text in _new_artifact_texts().items() + for literal in FORBIDDEN + if literal in text + ] + assert not offenders, ( + "боевые литералы в артефактах bundle (NFR-3/TC-06):\n" + "\n".join(offenders) + ) + + +def test_new_artifacts_carry_no_secret_like_values(): + offenders = [] + for label, text in _new_artifact_texts().items(): + for rx in (_SECRET_HEX_RE, _SECRET_ALNUM_RE): + m = rx.search(text) + if m is not None: + offenders.append(f"{label}: {m.group(0)[:16]}…") + assert not offenders, ( + "секретоподобные значения в артефактах bundle (NFR-3):\n" + "\n".join(offenders) + ) + + +def test_secret_heuristic_is_not_evergreen(): + """Негативный самочек (паттерн ORCH-101/102): эвристика реально ловит.""" + assert _SECRET_HEX_RE.search("KEY=" + "0fa1" * 16) is not None + assert _SECRET_ALNUM_RE.search("token" + "Ab1" * 15) is not None + assert _SECRET_HEX_RE.search("curl -fsS http://127.0.0.1:8500/health") is None + assert _SECRET_ALNUM_RE.search(" $ORCH_PLANE_API_TOKEN") is None + + +# --------------------------------------------------------------------------- +# TC-09: env-канон без фантомов + число статусов сверкой импорта. +# --------------------------------------------------------------------------- +def test_every_env_token_in_doc_exists_in_canons(): + canon = _env_keys(ENV_EXAMPLE) | _env_keys(BUNDLE_ENV_EXAMPLE) + mentioned = set(_ENV_TOKEN_RE.findall(_doc_text())) + assert mentioned, "в BUNDLED_SETUP.md не упомянут ни один env-ключ — док не полон" + unknown = sorted(mentioned - canon) + assert not unknown, ( + f"ключи из BUNDLED_SETUP.md отсутствуют в канонах (.env.example ∪ " + f"deploy/bundled/.env.example) — опечатка или дрейф (TC-09): {unknown}" + ) + + +def test_status_count_claim_matches_plane_sync(): + """«22 статуса» держится фактическим маппингом src/plane_sync.py (AC-7: + сверка импортом, не строковой копией).""" + from src.plane_sync import _PLANE_NAME_TO_KEY + + assert len(_PLANE_NAME_TO_KEY) == 22, ( + "число статусов в plane_sync изменилось — обнови BUNDLED_SETUP.md §7 " + "(и ONBOARDING.md §1)" + ) + assert "Confirm Deploy" in _PLANE_NAME_TO_KEY + assert "STOP" in _PLANE_NAME_TO_KEY + assert "22" in _doc_text(), "число статусов в BUNDLED_SETUP.md разъехалось с plane_sync" + + +# --------------------------------------------------------------------------- +# TC-10: канон не форкается — кросс-ссылки; REPLICATION §1 отмечает Type B. +# --------------------------------------------------------------------------- +def test_doc_links_canons_instead_of_forking(): + text = _doc_text() + for canon in ("LITE_SETUP.md", "ONBOARDING.md", "REPLICATION.md"): + assert canon in text, f"BUNDLED_SETUP.md не ссылается на канон {canon} (FR-4)" + bodies = _section_bodies() + assert "LITE_SETUP.md" in bodies["## 8. LLM (claude CLI)"], "§8 — ссылкой на LITE_SETUP §7" + assert "LITE_SETUP.md" in bodies["## 9. Telegram"], "§9 — ссылкой на LITE_SETUP §8" + assert "ONBOARDING.md" in bodies["## 10. Онбординг следующих проектов"] + assert "REPLICATION.md" in bodies["## 11. Smoke"], "smoke — на REPLICATION §4, без форка" + + +def test_replication_marks_type_b_done(): + text = REPLICATION.read_text(encoding="utf-8") + assert "BUNDLED_SETUP.md" in text, ( + "REPLICATION.md §1 обязан ссылаться на BUNDLED_SETUP.md (Type B реализован)" + ) + assert "ORCH-103" in text, "строка Type B в REPLICATION.md §1 не отмечена ✅ ORCH-103" + + +def test_lite_setup_untouched_reference_exists(): + """Канон Lite остаётся на месте (Bundled его дополняет, не заменяет).""" + assert LITE_SETUP.is_file() + + +# --------------------------------------------------------------------------- +# TC-11: CHANGELOG. +# --------------------------------------------------------------------------- +def test_changelog_has_orch_103_entry(): + assert "ORCH-103" in CHANGELOG.read_text(encoding="utf-8")